Merge branch 'develop' into sort-imports

Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
Aaron Raimist 2021-12-09 08:34:20 +00:00
commit 7b94e13a84
642 changed files with 30052 additions and 8035 deletions

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import Tooltip, { Alignment } from './Tooltip';
@ -70,13 +69,12 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, ...props } = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
const tip = this.state.hover && <Tooltip
tooltipClassName={tooltipClassName}
label={tooltip || title}
yOffset={yOffset}
alignment={alignment}
/> : null;
/>;
return (
<AccessibleButton
{...props}

View file

@ -19,11 +19,6 @@ limitations under the License.
import url from 'url';
import React, { createRef } from 'react';
import classNames from 'classnames';
import { MatrixCapabilities } from "matrix-widget-api";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
@ -32,22 +27,27 @@ import AppWarning from './AppWarning';
import Spinner from './Spinner';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { StopGapWidget } from "../../../stores/widgets/StopGapWidget";
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
import { MatrixCapabilities } from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import CallHandler from '../../../CallHandler';
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
import { WidgetLayoutStore, Container } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
app: IApp;
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: Room;
threadId?: string | null;
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth?: boolean;
@ -89,6 +89,8 @@ interface IState {
requiresClient: boolean;
}
import { logger } from "matrix-js-sdk/src/logger";
@replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
@ -99,6 +101,7 @@ export default class AppTile extends React.Component<IProps, IState> {
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
threadId: null,
};
private contextMenuButton = createRef<any>();
@ -227,7 +230,7 @@ export default class AppTile extends React.Component<IProps, IState> {
this.sgWidget.on("ready", this.onWidgetReady);
this.startWidget();
} catch (e) {
logger.log("Failed to construct widget", e);
logger.error("Failed to construct widget", e);
this.sgWidget = null;
}
}
@ -241,7 +244,13 @@ export default class AppTile extends React.Component<IProps, IState> {
private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref;
if (ref) {
if (this.sgWidget) this.sgWidget.start(ref);
try {
if (this.sgWidget) {
this.sgWidget.start(ref);
}
} catch (e) {
logger.error("Failed to start widget", e);
}
} else {
this.resetWidget(this.props);
}
@ -284,7 +293,7 @@ export default class AppTile extends React.Component<IProps, IState> {
}
if (WidgetType.JITSI.matches(this.props.app.type)) {
dis.dispatch({ action: 'hangup_conference' });
CallHandler.instance.hangupCallApp(this.props.room.roomId);
}
// Delete the widget from the persisted store for good measure.
@ -315,7 +324,13 @@ export default class AppTile extends React.Component<IProps, IState> {
switch (payload.action) {
case 'm.sticker':
if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
dis.dispatch({
action: 'post_sticker_message',
data: {
...payload.data,
threadId: this.props.threadId,
},
});
dis.dispatch({ action: 'stickerpicker_close' });
} else {
logger.warn('Ignoring sticker message. Invalid capability');
@ -394,6 +409,14 @@ export default class AppTile extends React.Component<IProps, IState> {
{ target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
};
private onMaxMinWidgetClick = (): void => {
const targetContainer =
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center)
? Container.Right
: Container.Center;
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
};
private onContextMenuClick = (): void => {
this.setState({ menuDisplayed: true });
};
@ -420,7 +443,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
const appTileBodyStyles = {};
if (this.props.pointerEvents) {
appTileBodyStyles['pointer-events'] = this.props.pointerEvents;
appTileBodyStyles['pointerEvents'] = this.props.pointerEvents;
}
const loadingElement = (
@ -516,6 +539,23 @@ export default class AppTile extends React.Component<IProps, IState> {
/>
);
}
let maxMinButton;
if (SettingsStore.getValue("feature_maximised_widgets")) {
const widgetIsMaximised = WidgetLayoutStore.instance.
isInContainer(this.props.room, this.props.app, Container.Center);
maxMinButton = <AccessibleButton
className={
"mx_AppTileMenuBar_iconButton"
+ (widgetIsMaximised
? " mx_AppTileMenuBar_iconButton_minWidget"
: " mx_AppTileMenuBar_iconButton_maxWidget")
}
title={
widgetIsMaximised ? _t('Close'): _t('Maximise widget')
}
onClick={this.onMaxMinWidgetClick}
/>;
}
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
@ -525,6 +565,7 @@ export default class AppTile extends React.Component<IProps, IState> {
{ this.props.showTitle && this.getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ maxMinButton }
{ (this.props.showPopout && !this.state.requiresClient) && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}

View file

@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import TagTile from './TagTile';
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
import React from 'react';
import ContextMenu, { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import * as sdk from '../../../index';
export default function DNDTagTile(props) {

View file

@ -178,26 +178,20 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onChevronClick = (ev: React.MouseEvent) => {
if (this.state.expanded) {
this.setState({ expanded: false });
ev.stopPropagation();
ev.preventDefault();
}
};
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
this.setState({
expanded: true,
});
this.setState({ expanded: true });
ev.preventDefault();
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
} else if (!(ev as React.KeyboardEvent).key) {
// collapse on other non-keyboard event activations
this.setState({ expanded: false });
ev.preventDefault();
}
};
@ -228,14 +222,22 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.close();
break;
case Key.ARROW_DOWN:
this.setState({
highlightedOption: this.nextOption(this.state.highlightedOption),
});
if (this.state.expanded) {
this.setState({
highlightedOption: this.nextOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
case Key.ARROW_UP:
this.setState({
highlightedOption: this.prevOption(this.state.highlightedOption),
});
if (this.state.expanded) {
this.setState({
highlightedOption: this.prevOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
default:
handled = false;
@ -383,7 +385,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span onClick={this.onChevronClick} className="mx_Dropdown_arrow" />
<span className="mx_Dropdown_arrow" />
{ menu }
</AccessibleButton>
</div>;

View file

@ -105,13 +105,19 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
</React.Fragment>;
}
let clearCacheButton: JSX.Element;
// we only show this button if there is an initialised MatrixClient otherwise we can't clear the cache
if (MatrixClientPeg.get()) {
clearCacheButton = <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{ _t("Clear cache and reload") }
</AccessibleButton>;
}
return <div className="mx_ErrorBoundary">
<div className="mx_ErrorBoundary_body">
<h1>{ _t("Something went wrong!") }</h1>
{ bugReportSection }
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{ _t("Clear cache and reload") }
</AccessibleButton>
{ clearCacheButton }
</div>
</div>;
}

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect } from "react";
import { uniqBy } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -22,7 +23,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { Layout } from '../../../settings/Layout';
import { Layout } from '../../../settings/enums/Layout';
interface IProps {
// An array of member events to summarise
@ -80,7 +81,8 @@ const EventListSummary: React.FC<IProps> = ({
{ children }
</React.Fragment>;
} else {
const avatars = summaryMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
const uniqueMembers = uniqBy(summaryMembers, member => member.getMxcAvatarUrl());
const avatars = uniqueMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
body = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">

View file

@ -22,7 +22,7 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import * as Avatar from '../../../Avatar';
import EventTile from '../rooms/EventTile';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';

View file

@ -46,6 +46,9 @@ interface IProps {
label?: string;
// The field's placeholder string. Defaults to the label.
placeholder?: string;
// When true (default false), the placeholder will be shown instead of the label when
// the component is unfocused & empty.
usePlaceholderAsHint?: boolean;
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode;
// Optional component to include inside the field after the input.
@ -227,6 +230,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
usePlaceholderAsHint,
...inputProps } = this.props;
// Set some defaults for the <input> element
@ -257,7 +261,8 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
mx_Field_placeholderIsHint: usePlaceholderAsHint,
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? !forceValidity

View file

@ -31,6 +31,7 @@ import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore";
import { formatFullDate } from "../../../DateUtils";
import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { normalizeWheelEvent } from "../../../utils/Mouse";
@ -333,7 +334,7 @@ export default class ImageView extends React.Component<IProps, IState> {
// matrix.to, but also for it to enable routing within Element when clicked.
ev.preventDefault();
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),

View file

@ -0,0 +1,496 @@
/*
Copyright 2019 - 2021 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 React, { CSSProperties, MouseEventHandler, ReactNode, RefCallback } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import UIStore from "../../../stores/UIStore";
import { ChevronFace } from "../../structures/ContextMenu";
const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container";
// If the distance from tooltip to window edge is below this value, the tooltip
// will flip around to the other side of the target.
const MIN_SAFE_DISTANCE_TO_WINDOW_EDGE = 20;
function getOrCreateContainer(): HTMLElement {
let container = document.getElementById(InteractiveTooltipContainerId);
if (!container) {
container = document.createElement("div");
container.id = InteractiveTooltipContainerId;
document.body.appendChild(container);
}
return container;
}
interface IRect {
top: number;
right: number;
bottom: number;
left: number;
}
function isInRect(x: number, y: number, rect: IRect): boolean {
const { top, right, bottom, left } = rect;
return x >= left && x <= right && y >= top && y <= bottom;
}
/**
* Returns the positive slope of the diagonal of the rect.
*
* @param {DOMRect} rect
* @return {number}
*/
function getDiagonalSlope(rect: IRect): number {
const { top, right, bottom, left } = rect;
return (bottom - top) / (right - left);
}
function isInUpperLeftHalf(x: number, y: number, rect: IRect): boolean {
const { bottom, left } = rect;
// Negative slope because Y values grow downwards and for this case, the
// diagonal goes from larger to smaller Y values.
const diagonalSlope = getDiagonalSlope(rect) * -1;
return isInRect(x, y, rect) && (y <= bottom + diagonalSlope * (x - left));
}
function isInLowerRightHalf(x: number, y: number, rect: IRect): boolean {
const { bottom, left } = rect;
// Negative slope because Y values grow downwards and for this case, the
// diagonal goes from larger to smaller Y values.
const diagonalSlope = getDiagonalSlope(rect) * -1;
return isInRect(x, y, rect) && (y >= bottom + diagonalSlope * (x - left));
}
function isInUpperRightHalf(x: number, y: number, rect: IRect): boolean {
const { top, left } = rect;
// Positive slope because Y values grow downwards and for this case, the
// diagonal goes from smaller to larger Y values.
const diagonalSlope = getDiagonalSlope(rect) * 1;
return isInRect(x, y, rect) && (y <= top + diagonalSlope * (x - left));
}
function isInLowerLeftHalf(x: number, y: number, rect: IRect): boolean {
const { top, left } = rect;
// Positive slope because Y values grow downwards and for this case, the
// diagonal goes from smaller to larger Y values.
const diagonalSlope = getDiagonalSlope(rect) * 1;
return isInRect(x, y, rect) && (y >= top + diagonalSlope * (x - left));
}
export enum Direction {
Top,
Left,
Bottom,
Right,
}
// exported for tests
export function mouseWithinRegion(
x: number,
y: number,
direction: Direction,
targetRect: DOMRect,
contentRect: DOMRect,
): boolean {
// When moving the mouse from the target to the tooltip, we create a safe area
// that includes the tooltip, the target, and the trapezoid ABCD between them:
// ┌───────────┐
// │ │
// │ │
// A └───E───F───┘ B
// V
// ┌─┐
// │ │
// C└─┘D
//
// As long as the mouse remains inside the safe area, the tooltip will stay open.
const buffer = 50;
if (isInRect(x, y, targetRect)) {
return true;
}
switch (direction) {
case Direction.Left: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right,
bottom: contentRect.bottom + buffer,
left: contentRect.left - buffer,
};
const trapezoidTop = {
top: contentRect.top - buffer,
right: targetRect.right,
bottom: targetRect.top,
left: contentRect.right,
};
const trapezoidCenter = {
top: targetRect.top,
right: targetRect.left,
bottom: targetRect.bottom,
left: contentRect.right,
};
const trapezoidBottom = {
top: targetRect.bottom,
right: targetRect.right,
bottom: contentRect.bottom + buffer,
left: contentRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerLeftHalf(x, y, trapezoidTop) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperLeftHalf(x, y, trapezoidBottom)
) {
return true;
}
break;
}
case Direction.Right: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right + buffer,
bottom: contentRect.bottom + buffer,
left: contentRect.left,
};
const trapezoidTop = {
top: contentRect.top - buffer,
right: contentRect.left,
bottom: targetRect.top,
left: targetRect.left,
};
const trapezoidCenter = {
top: targetRect.top,
right: contentRect.left,
bottom: targetRect.bottom,
left: targetRect.right,
};
const trapezoidBottom = {
top: targetRect.bottom,
right: contentRect.left,
bottom: contentRect.bottom + buffer,
left: targetRect.left,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerRightHalf(x, y, trapezoidTop) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperRightHalf(x, y, trapezoidBottom)
) {
return true;
}
break;
}
case Direction.Top: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right + buffer,
bottom: contentRect.bottom,
left: contentRect.left - buffer,
};
const trapezoidLeft = {
top: contentRect.bottom,
right: targetRect.left,
bottom: targetRect.bottom,
left: contentRect.left - buffer,
};
const trapezoidCenter = {
top: contentRect.bottom,
right: targetRect.right,
bottom: targetRect.top,
left: targetRect.left,
};
const trapezoidRight = {
top: contentRect.bottom,
right: contentRect.right + buffer,
bottom: targetRect.bottom,
left: targetRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInUpperRightHalf(x, y, trapezoidLeft) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperLeftHalf(x, y, trapezoidRight)
) {
return true;
}
break;
}
case Direction.Bottom: {
const contentRectWithBuffer = {
top: contentRect.top,
right: contentRect.right + buffer,
bottom: contentRect.bottom + buffer,
left: contentRect.left - buffer,
};
const trapezoidLeft = {
top: targetRect.top,
right: targetRect.left,
bottom: contentRect.top,
left: contentRect.left - buffer,
};
const trapezoidCenter = {
top: targetRect.bottom,
right: targetRect.right,
bottom: contentRect.top,
left: targetRect.left,
};
const trapezoidRight = {
top: targetRect.top,
right: contentRect.right + buffer,
bottom: contentRect.top,
left: targetRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerRightHalf(x, y, trapezoidLeft) ||
isInRect(x, y, trapezoidCenter) ||
isInLowerLeftHalf(x, y, trapezoidRight)
) {
return true;
}
break;
}
}
return false;
}
interface IProps {
children(props: {
ref: RefCallback<HTMLElement>;
onMouseOver: MouseEventHandler;
}): ReactNode;
// Content to show in the tooltip
content: ReactNode;
direction?: Direction;
// Function to call when visibility of the tooltip changes
onVisibilityChange?(visible: boolean): void;
}
interface IState {
contentRect: DOMRect;
visible: boolean;
}
/*
* This style of tooltip takes a "target" element as its child and centers the
* tooltip along one edge of the target.
*/
export default class InteractiveTooltip extends React.Component<IProps, IState> {
private target: HTMLElement;
public static defaultProps = {
side: Direction.Top,
};
constructor(props, context) {
super(props, context);
this.state = {
contentRect: null,
visible: false,
};
}
componentDidUpdate() {
// Whenever this passthrough component updates, also render the tooltip
// in a separate DOM tree. This allows the tooltip content to participate
// the normal React rendering cycle: when this component re-renders, the
// tooltip content re-renders.
// Once we upgrade to React 16, this could be done a bit more naturally
// using the portals feature instead.
this.renderTooltip();
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.onMouseMove);
}
private collectContentRect = (element: HTMLElement): void => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
this.setState({
contentRect: element.getBoundingClientRect(),
});
};
private collectTarget = (element: HTMLElement) => {
this.target = element;
};
private onLeftOfTarget(): boolean {
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (this.props.direction === Direction.Left) {
const targetLeft = targetRect.left + window.pageXOffset;
return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
} else {
const targetRight = targetRect.right + window.pageXOffset;
const spaceOnRight = UIStore.instance.windowWidth - targetRight;
return !contentRect || (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
}
}
private aboveTarget(): boolean {
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (this.props.direction === Direction.Top) {
const targetTop = targetRect.top + window.pageYOffset;
return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
} else {
const targetBottom = targetRect.bottom + window.pageYOffset;
const spaceBelow = UIStore.instance.windowHeight - targetBottom;
return !contentRect || (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
}
}
private get isOnTheSide(): boolean {
return this.props.direction === Direction.Left || this.props.direction === Direction.Right;
}
private onMouseMove = (ev: MouseEvent) => {
const { clientX: x, clientY: y } = ev;
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
let direction: Direction;
if (this.isOnTheSide) {
direction = this.onLeftOfTarget() ? Direction.Left : Direction.Right;
} else {
direction = this.aboveTarget() ? Direction.Top : Direction.Bottom;
}
if (!mouseWithinRegion(x, y, direction, targetRect, contentRect)) {
this.hideTooltip();
}
};
private onTargetMouseOver = (): void => {
this.showTooltip();
};
private showTooltip(): void {
// Don't enter visible state if we haven't collected the target yet
if (!this.target) return;
this.setState({
visible: true,
});
this.props.onVisibilityChange?.(true);
document.addEventListener("mousemove", this.onMouseMove);
}
public hideTooltip() {
this.setState({
visible: false,
});
this.props.onVisibilityChange?.(false);
document.removeEventListener("mousemove", this.onMouseMove);
}
private renderTooltip() {
const { contentRect, visible } = this.state;
if (!visible) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
return null;
}
const targetRect = this.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const targetLeft = targetRect.left + window.pageXOffset;
const targetRight = targetRect.right + window.pageXOffset;
const targetBottom = targetRect.bottom + window.pageYOffset;
const targetTop = targetRect.top + window.pageYOffset;
// Place the tooltip above the target by default. If we find that the
// tooltip content would extend past the safe area towards the window
// edge, flip around to below the target.
const position: Partial<IRect> = {};
let chevronFace: ChevronFace = null;
if (this.isOnTheSide) {
if (this.onLeftOfTarget()) {
position.left = targetLeft;
chevronFace = ChevronFace.Right;
} else {
position.left = targetRight;
chevronFace = ChevronFace.Left;
}
position.top = targetTop;
} else {
if (this.aboveTarget()) {
position.bottom = UIStore.instance.windowHeight - targetTop;
chevronFace = ChevronFace.Bottom;
} else {
position.top = targetBottom;
chevronFace = ChevronFace.Top;
}
// Center the tooltip horizontally with the target's center.
position.left = targetLeft + targetRect.width / 2;
}
const chevron = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />;
const menuClasses = classNames({
'mx_InteractiveTooltip': true,
'mx_InteractiveTooltip_withChevron_top': chevronFace === ChevronFace.Top,
'mx_InteractiveTooltip_withChevron_left': chevronFace === ChevronFace.Left,
'mx_InteractiveTooltip_withChevron_right': chevronFace === ChevronFace.Right,
'mx_InteractiveTooltip_withChevron_bottom': chevronFace === ChevronFace.Bottom,
});
const menuStyle: CSSProperties = {};
if (contentRect && !this.isOnTheSide) {
menuStyle.left = `-${contentRect.width / 2}px`;
}
const tooltip = <div className="mx_InteractiveTooltip_wrapper" style={{ ...position }}>
<div className={menuClasses} style={menuStyle} ref={this.collectContentRect}>
{ chevron }
{ this.props.content }
</div>
</div>;
ReactDOM.render(tooltip, getOrCreateContainer());
}
render() {
return this.props.children({
ref: this.collectTarget,
onMouseOver: this.onTargetMouseOver,
});
}
}

View file

@ -19,7 +19,6 @@ limitations under the License.
import React, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
@ -31,7 +30,8 @@ import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { Action } from '../../../dispatcher/actions';
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import { jsxJoin } from '../../../utils/ReactUtils';
import { Layout } from '../../../settings/Layout';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { Layout } from '../../../settings/enums/Layout';
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({

View file

@ -0,0 +1,180 @@
/*
Copyright 2021 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 ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal";
import { IDialogProps } from "../dialogs/IDialogProps";
import QuestionDialog from "../dialogs/QuestionDialog";
import React, { ChangeEvent, createRef } from "react";
import Modal from '../../../Modal';
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
import { arrayFastClone, arraySeed } from "../../../utils/arrays";
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts";
import Spinner from "./Spinner";
interface IProps extends IDialogProps {
room: Room;
}
interface IState extends IScrollableBaseState {
question: string;
options: string[];
busy: boolean;
}
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 20;
const DEFAULT_NUM_OPTIONS = 2;
const MAX_QUESTION_LENGTH = 340;
const MAX_OPTION_LENGTH = 340;
export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState> {
private addOptionRef = createRef<HTMLDivElement>();
public constructor(props: IProps) {
super(props);
this.state = {
title: _t("Create poll"),
actionLabel: _t("Create Poll"),
canSubmit: false, // need to add a question and at least one option first
question: "",
options: arraySeed("", DEFAULT_NUM_OPTIONS),
busy: false,
};
}
private checkCanSubmit() {
this.setState({
canSubmit:
!this.state.busy &&
this.state.question.trim().length > 0 &&
this.state.options.filter(op => op.trim().length > 0).length >= MIN_OPTIONS,
});
}
private onQuestionChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ question: e.target.value }, () => this.checkCanSubmit());
};
private onOptionChange = (i: number, e: ChangeEvent<HTMLInputElement>) => {
const newOptions = arrayFastClone(this.state.options);
newOptions[i] = e.target.value;
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionRemove = (i: number) => {
const newOptions = arrayFastClone(this.state.options);
newOptions.splice(i, 1);
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionAdd = () => {
const newOptions = arrayFastClone(this.state.options);
newOptions.push("");
this.setState({ options: newOptions }, () => {
// Scroll the button into view after the state update to ensure we don't experience
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
this.addOptionRef.current?.scrollIntoView?.();
});
};
protected submit(): void {
this.setState({ busy: true, canSubmit: false });
this.matrixClient.sendEvent(
this.props.room.roomId,
POLL_START_EVENT_TYPE.name,
makePollContent(
this.state.question, this.state.options, POLL_KIND_DISCLOSED.name,
),
).then(
() => this.props.onFinished(true),
).catch(e => {
console.error("Failed to post poll:", e);
Modal.createTrackedDialog(
'Failed to post poll',
'',
QuestionDialog,
{
title: _t("Failed to post poll"),
description: _t(
"Sorry, the poll you tried to create was not posted."),
button: _t('Try again'),
cancelButton: _t('Cancel'),
onFinished: (tryAgain: boolean) => {
if (!tryAgain) {
this.cancel();
} else {
this.setState({ busy: false, canSubmit: true });
}
},
},
);
});
}
protected cancel(): void {
this.props.onFinished(false);
}
protected renderContent(): React.ReactNode {
return <div className="mx_PollCreateDialog">
<h2>{ _t("What is your poll question or topic?") }</h2>
<Field
value={this.state.question}
maxLength={MAX_QUESTION_LENGTH}
label={_t("Question or topic")}
placeholder={_t("Write something...")}
onChange={this.onQuestionChange}
usePlaceholderAsHint={true}
disabled={this.state.busy}
/>
<h2>{ _t("Create options") }</h2>
{
this.state.options.map((op, i) => <div key={`option_${i}`} className="mx_PollCreateDialog_option">
<Field
value={op}
maxLength={MAX_OPTION_LENGTH}
label={_t("Option %(number)s", { number: i + 1 })}
placeholder={_t("Write an option")}
onChange={e => this.onOptionChange(i, e)}
usePlaceholderAsHint={true}
disabled={this.state.busy}
/>
<AccessibleButton
onClick={() => this.onOptionRemove(i)}
className="mx_PollCreateDialog_removeOption"
disabled={this.state.busy}
/>
</div>)
}
<AccessibleButton
onClick={this.onOptionAdd}
disabled={this.state.busy || this.state.options.length >= MAX_OPTIONS}
kind="secondary"
className="mx_PollCreateDialog_addOption"
inputRef={this.addOptionRef}
>{ _t("Add option") }</AccessibleButton>
{
this.state.busy &&
<div className="mx_PollCreateDialog_busy"><Spinner /></div>
}
</div>;
}
}

View file

@ -17,25 +17,25 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import escapeHtml from "escape-html";
import sanitizeHtml from "sanitize-html";
import { Room } from 'matrix-js-sdk/src/models/room';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
/**
* This number is based on the previous behavior - if we have message of height
@ -110,6 +110,8 @@ export default class ReplyChain extends React.Component<IProps, IState> {
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
} else if (!SettingsStore.getValue("feature_thread") && ev.isThreadRelation) {
return ev.threadRootId;
}
}

View file

@ -53,8 +53,10 @@ const onHelpClick = () => {
};
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
const disableCustomUrls = SdkConfig.get()["disable_custom_urls"];
let editBtn;
if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) {
if (!disableCustomUrls && onServerConfigChange) {
const onClick = () => {
showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => {
if (config) {
@ -83,7 +85,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
return <div className="mx_ServerPicker">
<h3>{ title || _t("Homeserver") }</h3>
<AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} />
{ !disableCustomUrls ? <AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} /> : null }
<span className="mx_ServerPicker_server">{ serverName }</span>
{ editBtn }
{ desc }

View file

@ -18,8 +18,15 @@ import React from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import classnames from 'classnames';
export enum CheckboxStyle {
Solid = "solid",
Outline = "outline",
}
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
kind?: CheckboxStyle;
}
interface IState {
@ -41,13 +48,21 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
public render() {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { children, className, ...otherProps } = this.props;
return <span className={"mx_Checkbox " + className}>
const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props;
const newClassName = classnames(
"mx_Checkbox",
className,
{
"mx_Checkbox_hasKind": kind,
[`mx_Checkbox_kind_${kind}`]: kind,
},
);
return <span className={newClassName}>
<input id={this.id} {...otherProps} type="checkbox" />
<label htmlFor={this.id}>
{ /* Using the div to center the image */ }
<div className="mx_Checkbox_background">
<img src={require("../../../../res/img/feather-customised/check.svg")} />
<div className="mx_Checkbox_checkmark" />
</div>
<div>
{ this.props.children }

View file

@ -40,13 +40,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
public render() {
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const _className = classnames(
'mx_RadioButton',
'mx_StyledRadioButton',
className,
{
"mx_RadioButton_disabled": disabled,
"mx_RadioButton_enabled": !disabled,
"mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined,
"mx_StyledRadioButton_disabled": disabled,
"mx_StyledRadioButton_enabled": !disabled,
"mx_StyledRadioButton_checked": this.props.checked,
"mx_StyledRadioButton_outlined": outlined,
});
const radioButton = <React.Fragment>
@ -58,16 +58,16 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
if (childrenInLabel) {
return <label className={_className}>
{ radioButton }
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
<div className="mx_StyledRadioButton_content">{ children }</div>
<div className="mx_StyledRadioButton_spacer" />
</label>;
} else {
return <div className={_className}>
<label className="mx_RadioButton_innerLabel">
<label className="mx_StyledRadioButton_innerLabel">
{ radioButton }
</label>
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
<div className="mx_StyledRadioButton_content">{ children }</div>
<div className="mx_StyledRadioButton_spacer" />
</div>;
}
}

View file

@ -21,6 +21,12 @@ import classNames from "classnames";
type Data = Pick<IFieldState, "value" | "allowEmpty">;
interface IResult {
key: string;
valid: boolean;
text: string;
}
interface IRule<T, D = void> {
key: string;
final?: boolean;
@ -32,7 +38,7 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> {
rules: IRule<T, D>[];
description?(this: T, derivedData: D): React.ReactChild;
description?(this: T, derivedData: D, results: IResult[]): React.ReactChild;
hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>;
}
@ -88,7 +94,7 @@ export default function withValidation<T = undefined, D = void>({
const data = { value, allowEmpty };
const derivedData = deriveData ? await deriveData(data) : undefined;
const results = [];
const results: IResult[] = [];
let valid = true;
if (rules && rules.length) {
for (const rule of rules) {
@ -164,8 +170,8 @@ export default function withValidation<T = undefined, D = void>({
if (description && (details || !hideDescriptionIfValid)) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this, derivedData);
summary = <div className="mx_Validation_description">{ content }</div>;
const content = description.call(this, derivedData, results);
summary = content ? <div className="mx_Validation_description">{ content }</div> : undefined;
}
let feedback;