Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into travis/blurhash
Conflicts: package.json src/components/views/messages/MImageBody.js yarn.lock
This commit is contained in:
commit
bf01ebae6d
1121 changed files with 97896 additions and 34373 deletions
|
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
|
|||
tabIndex?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?(e?: ButtonEvent): void;
|
||||
onClick(e?: ButtonEvent): void;
|
||||
}
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
|
|
|
@ -20,17 +20,21 @@ import classNames from 'classnames';
|
|||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Tooltip from './Tooltip';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
title: string;
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
forceHide?: boolean;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.AccessibleTooltipButton")
|
||||
export default class AccessibleTooltipButton extends React.PureComponent<ITooltipProps, IState> {
|
||||
constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
|
@ -39,7 +43,16 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<ITooltipProps>) {
|
||||
if (!prevProps.forceHide && this.props.forceHide && this.state.hover) {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
if (this.props.forceHide) return;
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
|
@ -52,12 +65,14 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
};
|
||||
|
||||
render() {
|
||||
const {title, tooltip, children, tooltipClassName, ...props} = this.props;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
yOffset={yOffset}
|
||||
/> : <div />;
|
||||
return (
|
||||
<AccessibleButton
|
||||
|
|
|
@ -16,16 +16,15 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as sdk from '../../../index';
|
||||
import Analytics from '../../../Analytics';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoleButton',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.ActionButton")
|
||||
export default class ActionButton extends React.Component {
|
||||
static propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
action: PropTypes.string.isRequired,
|
||||
|
@ -33,39 +32,36 @@ export default createReactClass({
|
|||
label: PropTypes.string.isRequired,
|
||||
iconPath: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
},
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
size: "25",
|
||||
tooltip: false,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
size: "25",
|
||||
tooltip: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
showTooltip: false,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
showTooltip: false,
|
||||
};
|
||||
|
||||
_onClick: function(ev) {
|
||||
_onClick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
Analytics.trackEvent('Action Button', 'click', this.props.action);
|
||||
dis.dispatch({action: this.props.action});
|
||||
},
|
||||
};
|
||||
|
||||
_onMouseEnter: function() {
|
||||
_onMouseEnter = () => {
|
||||
if (this.props.tooltip) this.setState({showTooltip: true});
|
||||
if (this.props.mouseOverAction) {
|
||||
dis.dispatch({action: this.props.mouseOverAction});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onMouseLeave: function() {
|
||||
_onMouseLeave = () => {
|
||||
this.setState({showTooltip: false});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
let tooltip;
|
||||
|
@ -75,8 +71,8 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
const icon = this.props.iconPath ?
|
||||
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
|
||||
undefined;
|
||||
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
|
||||
undefined;
|
||||
|
||||
const classNames = ["mx_RoleButton"];
|
||||
if (this.props.className) {
|
||||
|
@ -84,7 +80,8 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton className={classNames.join(" ")}
|
||||
<AccessibleButton
|
||||
className={classNames.join(" ")}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onMouseLeave={this._onMouseLeave}
|
||||
|
@ -92,7 +89,8 @@ export default createReactClass({
|
|||
>
|
||||
{ icon }
|
||||
{ tooltip }
|
||||
{ this.props.children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,15 +17,14 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
import classNames from 'classnames';
|
||||
import { UserAddressType } from '../../../UserAddress';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AddressSelector',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.AddressSelector")
|
||||
export default class AddressSelector extends React.Component {
|
||||
static propTypes = {
|
||||
onSelected: PropTypes.func.isRequired,
|
||||
|
||||
// List of the addresses to display
|
||||
|
@ -37,90 +36,91 @@ export default createReactClass({
|
|||
|
||||
// Element to put as a header on top of the list
|
||||
header: PropTypes.node,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: this.props.selected === undefined ? 0 : this.props.selected,
|
||||
hover: false,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(props) {
|
||||
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
|
||||
// Make sure the selected item isn't outside the list bounds
|
||||
const selected = this.state.selected;
|
||||
const maxSelected = this._maxSelected(props.addressList);
|
||||
if (selected > maxSelected) {
|
||||
this.setState({ selected: maxSelected });
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentDidUpdate() {
|
||||
// As the user scrolls with the arrow keys keep the selected item
|
||||
// at the top of the window.
|
||||
if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) {
|
||||
const elementHeight = this.addressListElement.getBoundingClientRect().height;
|
||||
this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
moveSelectionTop: function() {
|
||||
moveSelectionTop = () => {
|
||||
if (this.state.selected > 0) {
|
||||
this.setState({
|
||||
selected: 0,
|
||||
hover: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
moveSelectionUp: function() {
|
||||
moveSelectionUp = () => {
|
||||
if (this.state.selected > 0) {
|
||||
this.setState({
|
||||
selected: this.state.selected - 1,
|
||||
hover: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
moveSelectionDown: function() {
|
||||
moveSelectionDown = () => {
|
||||
if (this.state.selected < this._maxSelected(this.props.addressList)) {
|
||||
this.setState({
|
||||
selected: this.state.selected + 1,
|
||||
hover: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
chooseSelection: function() {
|
||||
chooseSelection = () => {
|
||||
this.selectAddress(this.state.selected);
|
||||
},
|
||||
};
|
||||
|
||||
onClick: function(index) {
|
||||
onClick = index => {
|
||||
this.selectAddress(index);
|
||||
},
|
||||
};
|
||||
|
||||
onMouseEnter: function(index) {
|
||||
onMouseEnter = index => {
|
||||
this.setState({
|
||||
selected: index,
|
||||
hover: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onMouseLeave: function() {
|
||||
onMouseLeave = () => {
|
||||
this.setState({ hover: false });
|
||||
},
|
||||
};
|
||||
|
||||
selectAddress: function(index) {
|
||||
selectAddress = index => {
|
||||
// Only try to select an address if one exists
|
||||
if (this.props.addressList.length !== 0) {
|
||||
this.props.onSelected(index);
|
||||
this.setState({ hover: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
createAddressListTiles: function() {
|
||||
const self = this;
|
||||
createAddressListTiles() {
|
||||
const AddressTile = sdk.getComponent("elements.AddressTile");
|
||||
const maxSelected = this._maxSelected(this.props.addressList);
|
||||
const addressList = [];
|
||||
|
@ -157,15 +157,15 @@ export default createReactClass({
|
|||
}
|
||||
}
|
||||
return addressList;
|
||||
},
|
||||
}
|
||||
|
||||
_maxSelected: function(list) {
|
||||
_maxSelected(list) {
|
||||
const listSize = list.length === 0 ? 0 : list.length - 1;
|
||||
const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
|
||||
return maxSelected;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const classes = classNames({
|
||||
"mx_AddressSelector": true,
|
||||
"mx_AddressSelector_empty": this.props.addressList.length === 0,
|
||||
|
@ -177,5 +177,5 @@ export default createReactClass({
|
|||
{ this.createAddressListTiles() }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,33 +17,29 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { UserAddressType } from '../../../UserAddress.js';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AddressTile',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.AddressTile")
|
||||
export default class AddressTile extends React.Component {
|
||||
static propTypes = {
|
||||
address: UserAddressType.isRequired,
|
||||
canDismiss: PropTypes.bool,
|
||||
onDismissed: PropTypes.func,
|
||||
justified: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
canDismiss: false,
|
||||
onDismissed: function() {}, // NOP
|
||||
justified: false,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
canDismiss: false,
|
||||
onDismissed: function() {}, // NOP
|
||||
justified: false,
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const address = this.props.address;
|
||||
const name = address.displayName || address.address;
|
||||
|
||||
|
@ -51,9 +47,7 @@ export default createReactClass({
|
|||
const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType);
|
||||
|
||||
if (isMatrixAddress && address.avatarMxc) {
|
||||
imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp(
|
||||
address.avatarMxc, 25, 25, 'crop',
|
||||
));
|
||||
imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
|
||||
} else if (address.addressType === 'email') {
|
||||
imgUrls.push(require("../../../../res/img/icon-email-user.svg"));
|
||||
}
|
||||
|
@ -144,5 +138,5 @@ export default createReactClass({
|
|||
{ dismiss }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,9 @@ import { _t } from '../../../languageHandler';
|
|||
import SdkConfig from '../../../SdkConfig';
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.AppPermission")
|
||||
export default class AppPermission extends React.Component {
|
||||
static propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
|
|
File diff suppressed because it is too large
Load diff
77
src/components/views/elements/DesktopBuildsNotice.tsx
Normal file
77
src/components/views/elements/DesktopBuildsNotice.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
Copyright 2020 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 EventIndexPeg from "../../../indexing/EventIndexPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import React from "react";
|
||||
|
||||
export enum WarningKind {
|
||||
Files,
|
||||
Search,
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
isRoomEncrypted: boolean;
|
||||
kind: WarningKind;
|
||||
}
|
||||
|
||||
export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) {
|
||||
if (!isRoomEncrypted) return null;
|
||||
if (EventIndexPeg.get()) return null;
|
||||
|
||||
const {desktopBuilds, brand} = SdkConfig.get();
|
||||
|
||||
let text = null;
|
||||
let logo = null;
|
||||
if (desktopBuilds.available) {
|
||||
logo = <img src={desktopBuilds.logo} />;
|
||||
switch (kind) {
|
||||
case WarningKind.Files:
|
||||
text = _t("Use the <a>Desktop app</a> to see all encrypted files", {}, {
|
||||
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{sub}</a>),
|
||||
});
|
||||
break;
|
||||
case WarningKind.Search:
|
||||
text = _t("Use the <a>Desktop app</a> to search encrypted messages", {}, {
|
||||
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{sub}</a>),
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (kind) {
|
||||
case WarningKind.Files:
|
||||
text = _t("This version of %(brand)s does not support viewing some encrypted files", {brand});
|
||||
break;
|
||||
case WarningKind.Search:
|
||||
text = _t("This version of %(brand)s does not support searching encrypted messages", {brand});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// for safety
|
||||
if (!text) {
|
||||
console.warn("Unknown desktop builds warning kind: ", kind);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_DesktopBuildsNotice">
|
||||
{logo}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
175
src/components/views/elements/DesktopCapturerSourcePicker.tsx
Normal file
175
src/components/views/elements/DesktopCapturerSourcePicker.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "..//dialogs/BaseDialog"
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import {getDesktopCapturerSources} from "matrix-js-sdk/src/webrtc/call";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL;
|
||||
}
|
||||
|
||||
export enum Tabs {
|
||||
Screens = "screens",
|
||||
Windows = "windows",
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourceIProps {
|
||||
source: DesktopCapturerSource;
|
||||
onSelect(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
this.props.onSelect(this.props.source);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_desktopCapturerSourcePicker_stream_button"
|
||||
title={this.props.source.name}
|
||||
onClick={this.onClick} >
|
||||
<img
|
||||
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
|
||||
src={this.props.source.thumbnailURL}
|
||||
/>
|
||||
<span className="mx_desktopCapturerSourcePicker_stream_name">{this.props.source.name}</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourcePickerIState {
|
||||
selectedTab: Tabs;
|
||||
sources: Array<DesktopCapturerSource>;
|
||||
}
|
||||
export interface DesktopCapturerSourcePickerIProps {
|
||||
onFinished(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
|
||||
export default class DesktopCapturerSourcePicker extends React.Component<
|
||||
DesktopCapturerSourcePickerIProps,
|
||||
DesktopCapturerSourcePickerIState
|
||||
> {
|
||||
interval;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedTab: Tabs.Screens,
|
||||
sources: [],
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
// setInterval() first waits and then executes, therefore
|
||||
// we call getDesktopCapturerSources() here without any delay.
|
||||
// Otherwise the dialog would be left empty for some time.
|
||||
this.setState({
|
||||
sources: await getDesktopCapturerSources(),
|
||||
});
|
||||
|
||||
// We update the sources every 500ms to get newer thumbnails
|
||||
this.interval = setInterval(async () => {
|
||||
this.setState({
|
||||
sources: await getDesktopCapturerSources(),
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
onSelect = (source) => {
|
||||
this.props.onFinished(source);
|
||||
}
|
||||
|
||||
onScreensClick = (ev) => {
|
||||
this.setState({selectedTab: Tabs.Screens});
|
||||
}
|
||||
|
||||
onWindowsClick = (ev) => {
|
||||
this.setState({selectedTab: Tabs.Windows});
|
||||
}
|
||||
|
||||
onCloseClick = (ev) => {
|
||||
this.props.onFinished(null);
|
||||
}
|
||||
|
||||
render() {
|
||||
let sources;
|
||||
if (this.state.selectedTab === Tabs.Screens) {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("screen");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
} else {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("window");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
}
|
||||
|
||||
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
|
||||
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
|
||||
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_desktopCapturerSourcePicker"
|
||||
onFinished={this.onCloseClick}
|
||||
title={_t("Share your screen")}
|
||||
>
|
||||
<div className="mx_desktopCapturerSourcePicker_tabLabels">
|
||||
<AccessibleButton
|
||||
className={screensButtonStyle}
|
||||
onClick={this.onScreensClick}
|
||||
>
|
||||
{_t("Screens")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className={windowsButtonStyle}
|
||||
onClick={this.onWindowsClick}
|
||||
>
|
||||
{_t("Windows")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_desktopCapturerSourcePicker_panel">
|
||||
{ sources }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,16 +18,15 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
/**
|
||||
* Basic container for buttons in modal dialogs.
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: "DialogButtons",
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.DialogButtons")
|
||||
export default class DialogButtons extends React.Component {
|
||||
static propTypes = {
|
||||
// The primary button which is styled differently and has default focus.
|
||||
primaryButton: PropTypes.node.isRequired,
|
||||
|
||||
|
@ -57,20 +56,21 @@ export default createReactClass({
|
|||
|
||||
// disables only the primary button
|
||||
primaryDisabled: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
// something to stick next to the buttons, optionally
|
||||
additive: PropTypes.element,
|
||||
};
|
||||
|
||||
_onCancelClick: function() {
|
||||
static defaultProps = {
|
||||
hasCancel: true,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
_onCancelClick = () => {
|
||||
this.props.onCancel();
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let primaryButtonClassName = "mx_Dialog_primary";
|
||||
if (this.props.primaryButtonClass) {
|
||||
primaryButtonClassName += " " + this.props.primaryButtonClass;
|
||||
|
@ -90,8 +90,14 @@ export default createReactClass({
|
|||
</button>;
|
||||
}
|
||||
|
||||
let additive = null;
|
||||
if (this.props.additive) {
|
||||
additive = <div className="mx_Dialog_buttons_additive">{this.props.additive}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_Dialog_buttons">
|
||||
{ additive }
|
||||
{ cancelButton }
|
||||
{ this.props.children }
|
||||
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
|
||||
|
@ -104,5 +110,5 @@ export default createReactClass({
|
|||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,13 +16,14 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.DirectorySearchBox")
|
||||
export default class DirectorySearchBox extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._collectInput = this._collectInput.bind(this);
|
||||
this._onClearClick = this._onClearClick.bind(this);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
|
@ -32,7 +33,7 @@ export default class DirectorySearchBox extends React.Component {
|
|||
this.input = null;
|
||||
|
||||
this.state = {
|
||||
value: '',
|
||||
value: this.props.initialText || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -78,28 +79,33 @@ export default class DirectorySearchBox extends React.Component {
|
|||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const searchbox_classes = {
|
||||
const searchboxClasses = {
|
||||
mx_DirectorySearchBox: true,
|
||||
};
|
||||
searchbox_classes[this.props.className] = true;
|
||||
searchboxClasses[this.props.className] = true;
|
||||
|
||||
let join_button;
|
||||
let joinButton;
|
||||
if (this.props.showJoinButton) {
|
||||
join_button = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
onClick={this._onJoinButtonClick}
|
||||
>{_t("Join")}</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className={`mx_DirectorySearchBox ${this.props.className} mx_textinput`}>
|
||||
<input type="text" name="dirsearch" value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange} onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder} autoFocus
|
||||
/>
|
||||
{ join_button }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick}></AccessibleButton>
|
||||
</div>;
|
||||
<input
|
||||
type="text"
|
||||
name="dirsearch"
|
||||
value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange}
|
||||
onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
{ joinButton }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,4 +116,5 @@ DirectorySearchBox.propTypes = {
|
|||
onJoinClick: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
showJoinButton: PropTypes.bool,
|
||||
initialText: PropTypes.string,
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
className: string;
|
||||
|
@ -33,8 +34,8 @@ export interface ILocationState {
|
|||
currentY: number;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.Draggable")
|
||||
export default class Draggable extends React.Component<IProps, IState> {
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -77,5 +78,4 @@ export default class Draggable extends React.Component<IProps, IState> {
|
|||
render() {
|
||||
return <div className={this.props.className} onMouseDown={this.onMouseDown.bind(this)} />;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import classnames from 'classnames';
|
|||
import AccessibleButton from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
class MenuOption extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -83,6 +84,7 @@ MenuOption.propTypes = {
|
|||
*
|
||||
* TODO: Port NetworkDropdown to use this.
|
||||
*/
|
||||
@replaceableComponent("views.elements.Dropdown")
|
||||
export default class Dropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
|
@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
|
|||
import {_t} from '../../../languageHandler';
|
||||
import Field from "./Field";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export class EditableItem extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -64,12 +65,18 @@ export class EditableItem extends React.Component {
|
|||
<span className="mx_EditableItem_promptText">
|
||||
{_t("Are you sure?")}
|
||||
</span>
|
||||
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm"
|
||||
className="mx_EditableItem_confirmBtn">
|
||||
<AccessibleButton
|
||||
onClick={this._onActuallyRemove}
|
||||
kind="primary_sm"
|
||||
className="mx_EditableItem_confirmBtn"
|
||||
>
|
||||
{_t("Yes")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm"
|
||||
className="mx_EditableItem_confirmBtn">
|
||||
<AccessibleButton
|
||||
onClick={this._onDontRemove}
|
||||
kind="danger_sm"
|
||||
className="mx_EditableItem_confirmBtn"
|
||||
>
|
||||
{_t("No")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
@ -85,6 +92,7 @@ export class EditableItem extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.EditableItemList")
|
||||
export default class EditableItemList extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
|
@ -119,12 +127,16 @@ export default class EditableItemList extends React.Component {
|
|||
|
||||
_renderNewItemField() {
|
||||
return (
|
||||
<form onSubmit={this._onItemAdded} autoComplete="off"
|
||||
noValidate={true} className="mx_EditableItemList_newItem">
|
||||
<form
|
||||
onSubmit={this._onItemAdded}
|
||||
autoComplete="off"
|
||||
noValidate={true}
|
||||
className="mx_EditableItemList_newItem"
|
||||
>
|
||||
<Field label={this.props.placeholder} type="text"
|
||||
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
|
||||
list={this.props.suggestionsListId} />
|
||||
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit">
|
||||
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
|
||||
list={this.props.suggestionsListId} />
|
||||
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
|
||||
{_t("Add")}
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
|
|
|
@ -17,13 +17,12 @@ limitations under the License.
|
|||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'EditableText',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.EditableText")
|
||||
export default class EditableText extends React.Component {
|
||||
static propTypes = {
|
||||
onValueChanged: PropTypes.func,
|
||||
initialValue: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
|
@ -36,60 +35,58 @@ export default createReactClass({
|
|||
// Will cause onValueChanged(value, true) to fire on blur
|
||||
blurToSubmit: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
Phases: {
|
||||
static Phases = {
|
||||
Display: "display",
|
||||
Edit: "edit",
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onValueChanged: function() {},
|
||||
initialValue: '',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
editable: true,
|
||||
className: "mx_EditableText",
|
||||
placeholderClassName: "mx_EditableText_placeholder",
|
||||
blurToSubmit: false,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
onValueChanged() {},
|
||||
initialValue: '',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
editable: true,
|
||||
className: "mx_EditableText",
|
||||
placeholderClassName: "mx_EditableText_placeholder",
|
||||
blurToSubmit: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: this.Phases.Display,
|
||||
};
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(nextProps) {
|
||||
if (nextProps.initialValue !== this.props.initialValue) {
|
||||
this.value = nextProps.initialValue;
|
||||
if (this._editable_div.current) {
|
||||
this.showPlaceholder(!this.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
// we track value as an JS object field rather than in React state
|
||||
// as React doesn't play nice with contentEditable.
|
||||
this.value = '';
|
||||
this.placeholder = false;
|
||||
|
||||
this._editable_div = createRef();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
state = {
|
||||
phase: EditableText.Phases.Display,
|
||||
};
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.initialValue !== this.props.initialValue) {
|
||||
this.value = nextProps.initialValue;
|
||||
if (this._editable_div.current) {
|
||||
this.showPlaceholder(!this.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.value = this.props.initialValue;
|
||||
if (this._editable_div.current) {
|
||||
this.showPlaceholder(!this.value);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
showPlaceholder: function(show) {
|
||||
showPlaceholder = show => {
|
||||
if (show) {
|
||||
this._editable_div.current.textContent = this.props.placeholder;
|
||||
this._editable_div.current.setAttribute("class", this.props.className
|
||||
|
@ -101,38 +98,36 @@ export default createReactClass({
|
|||
this._editable_div.current.setAttribute("class", this.props.className);
|
||||
this.placeholder = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
getValue: function() {
|
||||
return this.value;
|
||||
},
|
||||
getValue = () => this.value;
|
||||
|
||||
setValue: function(value) {
|
||||
setValue = value => {
|
||||
this.value = value;
|
||||
this.showPlaceholder(!this.value);
|
||||
},
|
||||
};
|
||||
|
||||
edit: function() {
|
||||
edit = () => {
|
||||
this.setState({
|
||||
phase: this.Phases.Edit,
|
||||
phase: EditableText.Phases.Edit,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
cancelEdit: function() {
|
||||
cancelEdit = () => {
|
||||
this.setState({
|
||||
phase: this.Phases.Display,
|
||||
phase: EditableText.Phases.Display,
|
||||
});
|
||||
this.value = this.props.initialValue;
|
||||
this.showPlaceholder(!this.value);
|
||||
this.onValueChanged(false);
|
||||
this._editable_div.current.blur();
|
||||
},
|
||||
};
|
||||
|
||||
onValueChanged: function(shouldSubmit) {
|
||||
onValueChanged = shouldSubmit => {
|
||||
this.props.onValueChanged(this.value, shouldSubmit);
|
||||
},
|
||||
};
|
||||
|
||||
onKeyDown: function(ev) {
|
||||
onKeyDown = ev => {
|
||||
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
|
||||
|
||||
if (this.placeholder) {
|
||||
|
@ -145,9 +140,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
|
||||
},
|
||||
};
|
||||
|
||||
onKeyUp: function(ev) {
|
||||
onKeyUp = ev => {
|
||||
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
|
||||
|
||||
if (!ev.target.textContent) {
|
||||
|
@ -163,17 +158,17 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
|
||||
},
|
||||
};
|
||||
|
||||
onClickDiv: function(ev) {
|
||||
onClickDiv = ev => {
|
||||
if (!this.props.editable) return;
|
||||
|
||||
this.setState({
|
||||
phase: this.Phases.Edit,
|
||||
phase: EditableText.Phases.Edit,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onFocus: function(ev) {
|
||||
onFocus = ev => {
|
||||
//ev.target.setSelectionRange(0, ev.target.textContent.length);
|
||||
|
||||
const node = ev.target.childNodes[0];
|
||||
|
@ -186,21 +181,21 @@ export default createReactClass({
|
|||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onFinish: function(ev, shouldSubmit) {
|
||||
onFinish = (ev, shouldSubmit) => {
|
||||
const self = this;
|
||||
const submit = (ev.key === Key.ENTER) || shouldSubmit;
|
||||
this.setState({
|
||||
phase: this.Phases.Display,
|
||||
phase: EditableText.Phases.Display,
|
||||
}, () => {
|
||||
if (this.value !== this.props.initialValue) {
|
||||
self.onValueChanged(submit);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onBlur: function(ev) {
|
||||
onBlur = ev => {
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
|
||||
|
@ -211,28 +206,32 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
this.showPlaceholder(!this.value);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const {className, editable, initialValue, label, labelClassName} = this.props;
|
||||
let editableEl;
|
||||
|
||||
if (!editable || (this.state.phase === this.Phases.Display && (label || labelClassName) && !this.value)) {
|
||||
if (!editable || (this.state.phase === EditableText.Phases.Display &&
|
||||
(label || labelClassName) && !this.value)
|
||||
) {
|
||||
// show the label
|
||||
editableEl = <div className={className + " " + labelClassName} onClick={this.onClickDiv}>
|
||||
{ label || initialValue }
|
||||
</div>;
|
||||
} else {
|
||||
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
|
||||
editableEl = <div ref={this._editable_div}
|
||||
contentEditable={true}
|
||||
className={className}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur} />;
|
||||
editableEl = <div
|
||||
ref={this._editable_div}
|
||||
contentEditable={true}
|
||||
className={className}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
/>;
|
||||
}
|
||||
|
||||
return editableEl;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
/**
|
||||
* A component which wraps an EditableText, with a spinner while updates take
|
||||
|
@ -29,6 +30,7 @@ import * as sdk from '../../../index';
|
|||
* similarly asynchronous way. If this is not provided, the initial value is
|
||||
* taken from the 'initialValue' property.
|
||||
*/
|
||||
@replaceableComponent("views.elements.EditableTextContainer")
|
||||
export default class EditableTextContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
94
src/components/views/elements/EffectsOverlay.tsx
Normal file
94
src/components/views/elements/EffectsOverlay.tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
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, { FunctionComponent, useEffect, useRef } from 'react';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ICanvasEffect from '../../../effects/ICanvasEffect';
|
||||
import {CHAT_EFFECTS} from '../../../effects'
|
||||
|
||||
interface IProps {
|
||||
roomWidth: number;
|
||||
}
|
||||
|
||||
const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const effectsRef = useRef<Map<string, ICanvasEffect>>(new Map<string, ICanvasEffect>());
|
||||
|
||||
const lazyLoadEffectModule = async (name: string): Promise<ICanvasEffect> => {
|
||||
if (!name) return null;
|
||||
let effect: ICanvasEffect | null = effectsRef.current[name] || null;
|
||||
if (effect === null) {
|
||||
const options = CHAT_EFFECTS.find((e) => e.command === name)?.options
|
||||
try {
|
||||
const { default: Effect } = await import(`../../../effects/${name}`);
|
||||
effect = new Effect(options);
|
||||
effectsRef.current[name] = effect;
|
||||
} catch (err) {
|
||||
console.warn(`Unable to load effect module at '../../../effects/${name}.`, err);
|
||||
}
|
||||
}
|
||||
return effect;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.height = window.innerHeight;
|
||||
}
|
||||
};
|
||||
const onAction = (payload: { action: string }) => {
|
||||
const actionPrefix = 'effects.';
|
||||
if (payload.action.indexOf(actionPrefix) === 0) {
|
||||
const effect = payload.action.substr(actionPrefix.length);
|
||||
lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current));
|
||||
}
|
||||
}
|
||||
const dispatcherRef = dis.register(onAction);
|
||||
const canvas = canvasRef.current;
|
||||
canvas.height = window.innerHeight;
|
||||
window.addEventListener('resize', resize, true);
|
||||
|
||||
return () => {
|
||||
dis.unregister(dispatcherRef);
|
||||
window.removeEventListener('resize', resize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
|
||||
for (const effect in currentEffects) {
|
||||
const effectModule: ICanvasEffect = currentEffects[effect];
|
||||
if (effectModule && effectModule.isRunning) {
|
||||
effectModule.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={roomWidth}
|
||||
style={{
|
||||
display: 'block',
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EffectsOverlay;
|
|
@ -20,11 +20,14 @@ import { _t } from '../../../languageHandler';
|
|||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import PlatformPeg from '../../../PlatformPeg';
|
||||
import Modal from '../../../Modal';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
/**
|
||||
* This error boundary component can be used to wrap large content areas and
|
||||
* catch exceptions during rendering in the component tree below them.
|
||||
*/
|
||||
@replaceableComponent("views.elements.ErrorBoundary")
|
||||
export default class ErrorBoundary extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -73,9 +76,10 @@ export default class ErrorBoundary extends React.PureComponent {
|
|||
if (this.state.error) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
|
||||
return <div className="mx_ErrorBoundary">
|
||||
<div className="mx_ErrorBoundary_body">
|
||||
<h1>{_t("Something went wrong!")}</h1>
|
||||
|
||||
let bugReportSection;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
bugReportSection = <React.Fragment>
|
||||
<p>{_t(
|
||||
"Please <newIssueLink>create a new issue</newIssueLink> " +
|
||||
"on GitHub so that we can investigate this bug.", {}, {
|
||||
|
@ -94,6 +98,13 @@ export default class ErrorBoundary extends React.PureComponent {
|
|||
<AccessibleButton onClick={this._onBugReport} kind='primary'>
|
||||
{_t("Submit debug logs")}
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2020 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.
|
||||
|
@ -14,15 +14,41 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ReactChildren, useEffect} from 'react';
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {MatrixEvent, RoomMember} from "matrix-js-sdk";
|
||||
import {useStateToggle} from "../../../hooks/useStateToggle";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => {
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
events: MatrixEvent[];
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold?: number;
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded?: boolean,
|
||||
// The list of room members for which to show avatars next to the summary
|
||||
summaryMembers?: RoomMember[],
|
||||
// The text to show as the summary of this event list
|
||||
summaryText?: string,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: ReactChildren,
|
||||
// Called when the event list expansion is toggled
|
||||
onToggle?(): void;
|
||||
}
|
||||
|
||||
const EventListSummary: React.FC<IProps> = ({
|
||||
events,
|
||||
children,
|
||||
threshold = 3,
|
||||
onToggle,
|
||||
startExpanded,
|
||||
summaryMembers = [],
|
||||
summaryText,
|
||||
}) => {
|
||||
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
|
||||
|
||||
// Whenever expanded changes call onToggle
|
||||
|
@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
|
|||
);
|
||||
};
|
||||
|
||||
EventListSummary.propTypes = {
|
||||
// An array of member events to summarise
|
||||
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: PropTypes.arrayOf(PropTypes.element).isRequired,
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold: PropTypes.number,
|
||||
// Called when the event list expansion is toggled
|
||||
onToggle: PropTypes.func,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded: PropTypes.bool,
|
||||
|
||||
// The list of room members for which to show avatars next to the summary
|
||||
summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)),
|
||||
// The text to show as the summary of this event list
|
||||
summaryText: PropTypes.string,
|
||||
};
|
||||
|
||||
export default EventListSummary;
|
|
@ -19,8 +19,11 @@ import classnames from 'classnames';
|
|||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import EventTile from '../rooms/EventTile';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {Layout} from "../../../settings/Layout";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
|
@ -31,81 +34,82 @@ interface IProps {
|
|||
/**
|
||||
* Whether to use the irc layout or not
|
||||
*/
|
||||
useIRCLayout: boolean;
|
||||
layout: Layout;
|
||||
|
||||
/**
|
||||
* classnames to apply to the wrapper of the preview
|
||||
*/
|
||||
className: string;
|
||||
|
||||
/**
|
||||
* The ID of the displayed user
|
||||
*/
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* The display name of the displayed user
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* The mxc:// avatar URL of the displayed user
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
userId: string;
|
||||
displayname: string;
|
||||
avatar_url: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = 32;
|
||||
|
||||
@replaceableComponent("views.elements.EventTilePreview")
|
||||
export default class EventTilePreview extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
userId: "@erim:fink.fink",
|
||||
displayname: "Erimayas Fink",
|
||||
avatar_url: null,
|
||||
message: props.message,
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
// Fetch current user data
|
||||
const client = MatrixClientPeg.get();
|
||||
const userId = client.getUserId();
|
||||
const profileInfo = await client.getProfileInfo(userId);
|
||||
const avatar_url = Avatar.avatarUrlForUser(
|
||||
{avatarUrl: profileInfo.avatar_url},
|
||||
AVATAR_SIZE, AVATAR_SIZE, "crop");
|
||||
|
||||
this.setState({
|
||||
userId,
|
||||
displayname: profileInfo.displayname,
|
||||
avatar_url,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private fakeEvent({userId, displayname, avatar_url}: IState) {
|
||||
private fakeEvent({message}: IState) {
|
||||
// Fake it till we make it
|
||||
const event = new MatrixEvent(JSON.parse(`{
|
||||
"type": "m.room.message",
|
||||
"sender": "${userId}",
|
||||
"content": {
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "${this.props.message}",
|
||||
"displayname": "${displayname}",
|
||||
"avatar_url": "${avatar_url}"
|
||||
},
|
||||
"msgtype": "m.text",
|
||||
"body": "${this.props.message}",
|
||||
"displayname": "${displayname}",
|
||||
"avatar_url": "${avatar_url}"
|
||||
/* eslint-disable quote-props */
|
||||
const rawEvent = {
|
||||
type: "m.room.message",
|
||||
sender: this.props.userId,
|
||||
content: {
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
body: message,
|
||||
displayname: this.props.displayName,
|
||||
avatar_url: this.props.avatarUrl,
|
||||
},
|
||||
"unsigned": {
|
||||
"age": 97
|
||||
},
|
||||
"event_id": "$9999999999999999999999999999999999999999999",
|
||||
"room_id": "!999999999999999999:matrix.org"
|
||||
}`));
|
||||
msgtype: "m.text",
|
||||
body: message,
|
||||
displayname: this.props.displayName,
|
||||
avatar_url: this.props.avatarUrl,
|
||||
},
|
||||
unsigned: {
|
||||
age: 97,
|
||||
},
|
||||
event_id: "$9999999999999999999999999999999999999999999",
|
||||
room_id: "!999999999999999999:example.org",
|
||||
};
|
||||
const event = new MatrixEvent(rawEvent);
|
||||
/* eslint-enable quote-props */
|
||||
|
||||
// Fake it more
|
||||
event.sender = {
|
||||
name: displayname,
|
||||
userId: userId,
|
||||
name: this.props.displayName,
|
||||
userId: this.props.userId,
|
||||
getAvatarUrl: (..._) => {
|
||||
return avatar_url;
|
||||
return Avatar.avatarUrlForUser(
|
||||
{ avatarUrl: this.props.avatarUrl },
|
||||
AVATAR_SIZE, AVATAR_SIZE, "crop",
|
||||
);
|
||||
},
|
||||
getMxcAvatarUrl: () => this.props.avatarUrl,
|
||||
};
|
||||
|
||||
return event;
|
||||
|
@ -114,16 +118,17 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
|
|||
public render() {
|
||||
const event = this.fakeEvent(this.state);
|
||||
|
||||
let className = classnames(
|
||||
this.props.className,
|
||||
{
|
||||
"mx_IRCLayout": this.props.useIRCLayout,
|
||||
"mx_GroupLayout": !this.props.useIRCLayout,
|
||||
}
|
||||
);
|
||||
const className = classnames(this.props.className, {
|
||||
"mx_IRCLayout": this.props.layout == Layout.IRC,
|
||||
"mx_GroupLayout": this.props.layout == Layout.Group,
|
||||
});
|
||||
|
||||
return <div className={className}>
|
||||
<EventTile mxEvent={event} useIRCLayout={this.props.useIRCLayout} />
|
||||
<EventTile
|
||||
mxEvent={event}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
89
src/components/views/elements/FacePile.tsx
Normal file
89
src/components/views/elements/FacePile.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
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 React, { HTMLAttributes, ReactNode, useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import { useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const DEFAULT_NUM_FACES = 5;
|
||||
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
room: Room;
|
||||
onlyKnownUsers?: boolean;
|
||||
numShown?: number;
|
||||
}
|
||||
|
||||
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
|
||||
|
||||
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
let members = useRoomMembers(room);
|
||||
|
||||
// sort users with an explicit avatar first
|
||||
const iteratees = [member => !!member.getMxcAvatarUrl()];
|
||||
if (onlyKnownUsers) {
|
||||
members = members.filter(isKnownMember);
|
||||
} else {
|
||||
// sort known users first
|
||||
iteratees.unshift(member => isKnownMember(member));
|
||||
}
|
||||
|
||||
// exclude ourselves from the shown members list
|
||||
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
|
||||
if (shownMembers.length < 1) return null;
|
||||
|
||||
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
|
||||
// reverse members in tooltip order to make the order between the two match up.
|
||||
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
|
||||
|
||||
let tooltip: ReactNode;
|
||||
if (props.onClick) {
|
||||
tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("View all %(count)s members", { count: members.length }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) }
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
|
||||
count: members.length,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
}
|
||||
|
||||
return <div {...props} className="mx_FacePile">
|
||||
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
|
||||
{ shownMembers.map(m =>
|
||||
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
|
||||
</TextWithTooltip>
|
||||
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||
</span> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default FacePile;
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { debounce } from 'lodash';
|
||||
import {debounce} from "lodash";
|
||||
import {IFieldState, IValidationResult} from "./Validation";
|
||||
|
||||
// Invoke validation from user input (when typing, etc.) at most once every N ms.
|
||||
|
@ -61,10 +61,14 @@ interface IProps {
|
|||
tooltipClassName?: string;
|
||||
// If specified, an additional class name to apply to the field container
|
||||
className?: string;
|
||||
// On what events should validation occur; by default on all
|
||||
validateOnFocus?: boolean;
|
||||
validateOnBlur?: boolean;
|
||||
validateOnChange?: boolean;
|
||||
// All other props pass through to the <input>.
|
||||
}
|
||||
|
||||
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The element to create. Defaults to "input".
|
||||
element?: "input";
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
|
@ -100,6 +104,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
public static readonly defaultProps = {
|
||||
element: "input",
|
||||
type: "text",
|
||||
validateOnFocus: true,
|
||||
validateOnBlur: true,
|
||||
validateOnChange: true,
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -137,9 +144,11 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
this.setState({
|
||||
focused: true,
|
||||
});
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
if (this.props.validateOnFocus) {
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
}
|
||||
// Parent component may have supplied its own `onFocus` as well
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
|
@ -147,7 +156,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
};
|
||||
|
||||
private onChange = (ev) => {
|
||||
this.validateOnChange();
|
||||
if (this.props.validateOnChange) {
|
||||
this.validateOnChange();
|
||||
}
|
||||
// Parent component may have supplied its own `onChange` as well
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(ev);
|
||||
|
@ -158,16 +169,18 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
this.setState({
|
||||
focused: false,
|
||||
});
|
||||
this.validate({
|
||||
focused: false,
|
||||
});
|
||||
if (this.props.validateOnBlur) {
|
||||
this.validate({
|
||||
focused: false,
|
||||
});
|
||||
}
|
||||
// Parent component may have supplied its own `onBlur` as well
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
|
||||
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) {
|
||||
if (!this.props.onValidate) {
|
||||
return;
|
||||
}
|
||||
|
@ -196,14 +209,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
feedbackVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public render() {
|
||||
const {
|
||||
element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
|
||||
...inputProps} = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
|
@ -248,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
|
||||
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
|
||||
label={tooltipContent || this.state.feedback}
|
||||
forceOnRight
|
||||
alignment={Tooltip.Alignment.Right}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,13 +14,13 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FlairStore from '../../../stores/FlairStore';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
|
||||
|
||||
class FlairAvatar extends React.Component {
|
||||
|
@ -40,8 +40,7 @@ class FlairAvatar extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const httpUrl = this.context.mxcUrlToHttp(
|
||||
this.props.groupProfile.avatarUrl, 16, 16, 'scale', false);
|
||||
const httpUrl = mediaFromMxc(this.props.groupProfile.avatarUrl).getSquareThumbnailHttp(16);
|
||||
const tooltip = this.props.groupProfile.name ?
|
||||
`${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`:
|
||||
this.props.groupProfile.groupId;
|
||||
|
@ -64,6 +63,7 @@ FlairAvatar.propTypes = {
|
|||
|
||||
FlairAvatar.contextType = MatrixClientContext;
|
||||
|
||||
@replaceableComponent("views.elements.Flair")
|
||||
export default class Flair extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
|
|
@ -18,6 +18,7 @@ import React from 'react';
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Draggable, {ILocationState} from './Draggable';
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// Current room
|
||||
|
@ -31,6 +32,7 @@ interface IState {
|
|||
IRCLayoutRoot: HTMLElement;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.IRCTimelineProfileResizer")
|
||||
export default class IRCTimelineProfileResizer extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -78,7 +80,12 @@ export default class IRCTimelineProfileResizer extends React.Component<IProps, I
|
|||
|
||||
private onMoueUp(event: MouseEvent) {
|
||||
if (this.props.roomId) {
|
||||
SettingsStore.setValue("ircDisplayNameWidth", this.props.roomId, SettingLevel.ROOM_DEVICE, this.state.width);
|
||||
SettingsStore.setValue(
|
||||
"ircDisplayNameWidth",
|
||||
this.props.roomId,
|
||||
SettingLevel.ROOM_DEVICE,
|
||||
this.state.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
export default function IconButton(props) {
|
||||
const {icon, className, ...restProps} = props;
|
||||
|
||||
let newClassName = (className || "") + " mx_IconButton";
|
||||
newClassName = newClassName + " mx_IconButton_icon_" + icon;
|
||||
|
||||
const allProps = Object.assign({}, restProps, {className: newClassName});
|
||||
|
||||
return React.createElement(AccessibleButton, allProps);
|
||||
}
|
||||
|
||||
IconButton.propTypes = Object.assign({
|
||||
icon: PropTypes.string,
|
||||
}, AccessibleButton.propTypes);
|
|
@ -1,233 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {formatDate} from '../../../DateUtils';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import filesize from "filesize";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Modal from "../../../Modal";
|
||||
import * as sdk from "../../../index";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import FocusLock from "react-focus-lock";
|
||||
|
||||
export default class ImageView extends React.Component {
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired, // the source of the image being displayed
|
||||
name: PropTypes.string, // the main title ('name') for the image
|
||||
link: PropTypes.string, // the link (if any) applied to the name of the image
|
||||
width: PropTypes.number, // width of the image src in pixels
|
||||
height: PropTypes.number, // height of the image src in pixels
|
||||
fileSize: PropTypes.number, // size of the image src in bytes
|
||||
onFinished: PropTypes.func.isRequired, // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { rotationDegrees: 0 };
|
||||
}
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
onRedactClick = () => {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
this.props.onFinished();
|
||||
MatrixClientPeg.get().redactEvent(
|
||||
this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(),
|
||||
).catch(function(e) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
// display error message stating you couldn't delete this.
|
||||
const code = e.errcode || e.statusCode;
|
||||
Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('You cannot delete this image. (%(code)s)', {code: code}),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
getName() {
|
||||
let name = this.props.name;
|
||||
if (name && this.props.link) {
|
||||
name = <a href={ this.props.link } target="_blank" rel="noreferrer noopener">{ name }</a>;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
rotateCounterClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur - 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
rotateClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur + 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
render() {
|
||||
/*
|
||||
// In theory max-width: 80%, max-height: 80% on the CSS should work
|
||||
// but in practice, it doesn't, so do it manually:
|
||||
|
||||
var width = this.props.width || 500;
|
||||
var height = this.props.height || 500;
|
||||
|
||||
var maxWidth = document.documentElement.clientWidth * 0.8;
|
||||
var maxHeight = document.documentElement.clientHeight * 0.8;
|
||||
|
||||
var widthFrac = width / maxWidth;
|
||||
var heightFrac = height / maxHeight;
|
||||
|
||||
var displayWidth;
|
||||
var displayHeight;
|
||||
if (widthFrac > heightFrac) {
|
||||
displayWidth = Math.min(width, maxWidth);
|
||||
displayHeight = (displayWidth / width) * height;
|
||||
} else {
|
||||
displayHeight = Math.min(height, maxHeight);
|
||||
displayWidth = (displayHeight / height) * width;
|
||||
}
|
||||
|
||||
var style = {
|
||||
width: displayWidth,
|
||||
height: displayHeight
|
||||
};
|
||||
*/
|
||||
let style = {};
|
||||
let res;
|
||||
|
||||
if (this.props.width && this.props.height) {
|
||||
style = {
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
};
|
||||
res = style.width + "x" + style.height + "px";
|
||||
}
|
||||
|
||||
let size;
|
||||
if (this.props.fileSize) {
|
||||
size = filesize(this.props.fileSize);
|
||||
}
|
||||
|
||||
let sizeRes;
|
||||
if (size && res) {
|
||||
sizeRes = size + ", " + res;
|
||||
} else {
|
||||
sizeRes = size || res;
|
||||
}
|
||||
|
||||
let mayRedact = false;
|
||||
const showEventMeta = !!this.props.mxEvent;
|
||||
|
||||
let eventMeta;
|
||||
if (showEventMeta) {
|
||||
// Figure out the sender, defaulting to mxid
|
||||
let sender = this.props.mxEvent.getSender();
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
if (room) {
|
||||
mayRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
|
||||
const member = room.getMember(sender);
|
||||
if (member) sender = member.name;
|
||||
}
|
||||
|
||||
eventMeta = (<div className="mx_ImageView_metadata">
|
||||
{ _t('Uploaded on %(date)s by %(user)s', {
|
||||
date: formatDate(new Date(this.props.mxEvent.getTs())),
|
||||
user: sender,
|
||||
}) }
|
||||
</div>);
|
||||
}
|
||||
|
||||
let eventRedact;
|
||||
if (mayRedact) {
|
||||
eventRedact = (<div className="mx_ImageView_button" onClick={this.onRedactClick}>
|
||||
{ _t('Remove') }
|
||||
</div>);
|
||||
}
|
||||
|
||||
const rotationDegrees = this.state.rotationDegrees;
|
||||
const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style};
|
||||
|
||||
return (
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
}}
|
||||
className="mx_ImageView"
|
||||
>
|
||||
<div className="mx_ImageView_lhs">
|
||||
</div>
|
||||
<div className="mx_ImageView_content">
|
||||
<img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" />
|
||||
<div className="mx_ImageView_labelWrapper">
|
||||
<div className="mx_ImageView_label">
|
||||
<AccessibleButton className="mx_ImageView_rotateCounterClockwise" title={_t("Rotate Left")} onClick={ this.rotateCounterClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_rotateClockwise" title={_t("Rotate Right")} onClick={ this.rotateClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_cancel" title={_t("Close")} onClick={ this.props.onFinished }>
|
||||
<img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } />
|
||||
</AccessibleButton>
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
<div className="mx_ImageView_name">
|
||||
{ this.getName() }
|
||||
</div>
|
||||
{ eventMeta }
|
||||
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
|
||||
<div className="mx_ImageView_download">
|
||||
{ _t('Download this file') }<br />
|
||||
<span className="mx_ImageView_size">{ sizeRes }</span>
|
||||
</div>
|
||||
</a>
|
||||
{ eventRedact }
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_ImageView_rhs">
|
||||
</div>
|
||||
</FocusLock>
|
||||
);
|
||||
}
|
||||
}
|
491
src/components/views/elements/ImageView.tsx
Normal file
491
src/components/views/elements/ImageView.tsx
Normal file
|
@ -0,0 +1,491 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2020, 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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, { createRef } from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||
import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu';
|
||||
import MessageTimestamp from "../messages/MessageTimestamp";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {formatFullDate} from "../../../DateUtils";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {normalizeWheelEvent} from "../../../utils/Mouse";
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
// This is used for the buttons
|
||||
const ZOOM_STEP = 0.10;
|
||||
// This is used for mouse wheel events
|
||||
const ZOOM_COEFFICIENT = 0.0025;
|
||||
// If we have moved only this much we can zoom
|
||||
const ZOOM_DISTANCE = 10;
|
||||
|
||||
interface IProps {
|
||||
src: string, // the source of the image being displayed
|
||||
name?: string, // the main title ('name') for the image
|
||||
link?: string, // the link (if any) applied to the name of the image
|
||||
width?: number, // width of the image src in pixels
|
||||
height?: number, // height of the image src in pixels
|
||||
fileSize?: number, // size of the image src in bytes
|
||||
onFinished(): void, // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: MatrixEvent,
|
||||
permalinkCreator: RoomPermalinkCreator,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
zoom: number,
|
||||
minZoom: number,
|
||||
maxZoom: number,
|
||||
rotation: number,
|
||||
translationX: number,
|
||||
translationY: number,
|
||||
moving: boolean,
|
||||
contextMenuDisplayed: boolean,
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.ImageView")
|
||||
export default class ImageView extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
zoom: 0,
|
||||
minZoom: MAX_SCALE,
|
||||
maxZoom: MAX_SCALE,
|
||||
rotation: 0,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
moving: false,
|
||||
contextMenuDisplayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// XXX: Refs to functional components
|
||||
private contextMenuButton = createRef<any>();
|
||||
private focusLock = createRef<any>();
|
||||
private imageWrapper = createRef<HTMLDivElement>();
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private lastX = 0;
|
||||
private lastY = 0;
|
||||
private previousX = 0;
|
||||
private previousY = 0;
|
||||
|
||||
componentDidMount() {
|
||||
// We have to use addEventListener() because the listener
|
||||
// needs to be passive in order to work with Chromium
|
||||
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
|
||||
// We want to recalculate zoom whenever the window's size changes
|
||||
window.addEventListener("resize", this.calculateZoom);
|
||||
// After the image loads for the first time we want to calculate the zoom
|
||||
this.image.current.addEventListener("load", this.calculateZoom);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.focusLock.current.removeEventListener('wheel', this.onWheel);
|
||||
window.removeEventListener("resize", this.calculateZoom);
|
||||
this.image.current.removeEventListener("load", this.calculateZoom);
|
||||
}
|
||||
|
||||
private calculateZoom = () => {
|
||||
const image = this.image.current;
|
||||
const imageWrapper = this.imageWrapper.current;
|
||||
|
||||
const zoomX = imageWrapper.clientWidth / image.naturalWidth;
|
||||
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
|
||||
|
||||
// If the image is smaller in both dimensions set its the zoom to 1 to
|
||||
// display it in its original size
|
||||
if (zoomX >= 1 && zoomY >= 1) {
|
||||
this.setState({
|
||||
zoom: 1,
|
||||
minZoom: 1,
|
||||
maxZoom: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
|
||||
// any direction. We also multiply by MAX_SCALE to get a gap around the
|
||||
// image by default
|
||||
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
|
||||
|
||||
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
|
||||
this.setState({
|
||||
minZoom: minZoom,
|
||||
maxZoom: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newZoom = this.state.zoom + delta;
|
||||
|
||||
if (newZoom <= this.state.minZoom) {
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (newZoom >= this.state.maxZoom) {
|
||||
this.setState({zoom: this.state.maxZoom});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
zoom: newZoom,
|
||||
});
|
||||
}
|
||||
|
||||
private onWheel = (ev: WheelEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
const {deltaY} = normalizeWheelEvent(ev);
|
||||
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
|
||||
};
|
||||
|
||||
private onZoomInClick = () => {
|
||||
this.zoom(ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onZoomOutClick = () => {
|
||||
this.zoom(-ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
private onRotateCounterClockwiseClick = () => {
|
||||
const cur = this.state.rotation;
|
||||
const rotationDegrees = cur - 90;
|
||||
this.setState({ rotation: rotationDegrees });
|
||||
};
|
||||
|
||||
private onRotateClockwiseClick = () => {
|
||||
const cur = this.state.rotation;
|
||||
const rotationDegrees = cur + 90;
|
||||
this.setState({ rotation: rotationDegrees });
|
||||
};
|
||||
|
||||
private onDownloadClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = this.props.src;
|
||||
a.download = this.props.name;
|
||||
a.target = "_blank";
|
||||
a.rel = "noreferrer noopener";
|
||||
a.click();
|
||||
};
|
||||
|
||||
private onOpenContextMenu = () => {
|
||||
this.setState({
|
||||
contextMenuDisplayed: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseContextMenu = () => {
|
||||
this.setState({
|
||||
contextMenuDisplayed: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onPermalinkClicked = (ev: React.MouseEvent) => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
ev.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
});
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
private onStartMoving = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// Don't do anything if we pressed any
|
||||
// other button than the left one
|
||||
if (ev.button !== 0) return;
|
||||
|
||||
// Zoom in if we are completely zoomed out
|
||||
if (this.state.zoom === this.state.minZoom) {
|
||||
this.setState({zoom: this.state.maxZoom});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({moving: true});
|
||||
this.previousX = this.state.translationX;
|
||||
this.previousY = this.state.translationY;
|
||||
this.initX = ev.pageX - this.lastX;
|
||||
this.initY = ev.pageY - this.lastY;
|
||||
};
|
||||
|
||||
private onMoving = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.state.moving) return;
|
||||
|
||||
this.lastX = ev.pageX - this.initX;
|
||||
this.lastY = ev.pageY - this.initY;
|
||||
this.setState({
|
||||
translationX: this.lastX,
|
||||
translationY: this.lastY,
|
||||
});
|
||||
};
|
||||
|
||||
private onEndMoving = () => {
|
||||
// Zoom out if we haven't moved much
|
||||
if (
|
||||
this.state.moving === true &&
|
||||
Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE &&
|
||||
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
|
||||
) {
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
}
|
||||
this.setState({moving: false});
|
||||
};
|
||||
|
||||
private renderContextMenu() {
|
||||
let contextMenu = null;
|
||||
if (this.state.contextMenuDisplayed) {
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
|
||||
onFinished={this.onCloseContextMenu}
|
||||
>
|
||||
<MessageContextMenu
|
||||
mxEvent={this.props.mxEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFinished={this.onCloseContextMenu}
|
||||
onCloseDialog={this.props.onFinished}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{ contextMenu }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const showEventMeta = !!this.props.mxEvent;
|
||||
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
|
||||
|
||||
let cursor;
|
||||
if (this.state.moving) {
|
||||
cursor= "grabbing";
|
||||
} else if (zoomingDisabled) {
|
||||
cursor = "default";
|
||||
} else if (this.state.zoom === this.state.minZoom) {
|
||||
cursor = "zoom-in";
|
||||
} else {
|
||||
cursor = "zoom-out";
|
||||
}
|
||||
const rotationDegrees = this.state.rotation + "deg";
|
||||
const zoom = this.state.zoom;
|
||||
const translatePixelsX = this.state.translationX + "px";
|
||||
const translatePixelsY = this.state.translationY + "px";
|
||||
// The order of the values is important!
|
||||
// First, we translate and only then we rotate, otherwise
|
||||
// we would apply the translation to an already rotated
|
||||
// image causing it translate in the wrong direction.
|
||||
const style = {
|
||||
cursor: cursor,
|
||||
transition: this.state.moving ? null : "transform 200ms ease 0s",
|
||||
transform: `translateX(${translatePixelsX})
|
||||
translateY(${translatePixelsY})
|
||||
scale(${zoom})
|
||||
rotate(${rotationDegrees})`,
|
||||
};
|
||||
|
||||
let info;
|
||||
if (showEventMeta) {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
|
||||
const sender = (
|
||||
<div className="mx_ImageView_info_sender">
|
||||
{senderName}
|
||||
</div>
|
||||
);
|
||||
const messageTimestamp = (
|
||||
<a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatFullDate(new Date(this.props.mxEvent.getTs()), showTwelveHour, false)}
|
||||
>
|
||||
<MessageTimestamp
|
||||
showFullDate={true}
|
||||
showTwelveHour={showTwelveHour}
|
||||
ts={mxEvent.getTs()}
|
||||
showSeconds={false}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
const avatar = (
|
||||
<MemberAvatar
|
||||
member={mxEvent.sender}
|
||||
width={32} height={32}
|
||||
viewUserOnClick={true}
|
||||
/>
|
||||
);
|
||||
|
||||
info = (
|
||||
<div className="mx_ImageView_info_wrapper">
|
||||
{avatar}
|
||||
<div className="mx_ImageView_info">
|
||||
{sender}
|
||||
{messageTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// If there is no event - we're viewing an avatar, we set
|
||||
// an empty div here, since the panel uses space-between
|
||||
// and we want the same placement of elements
|
||||
info = (
|
||||
<div></div>
|
||||
);
|
||||
}
|
||||
|
||||
let contextMenuButton;
|
||||
if (this.props.mxEvent) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_more"
|
||||
title={_t("Options")}
|
||||
onClick={this.onOpenContextMenu}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.contextMenuDisplayed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let zoomOutButton;
|
||||
let zoomInButton;
|
||||
if (!zoomingDisabled) {
|
||||
zoomOutButton = (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomOut"
|
||||
title={_t("Zoom out")}
|
||||
onClick={this.onZoomOutClick}>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
zoomInButton = (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomIn"
|
||||
title={_t("Zoom in")}
|
||||
onClick={ this.onZoomInClick }>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
}}
|
||||
className="mx_ImageView"
|
||||
ref={this.focusLock}
|
||||
>
|
||||
<div className="mx_ImageView_panel">
|
||||
{info}
|
||||
<div className="mx_ImageView_toolbar">
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCCW"
|
||||
title={_t("Rotate Left")}
|
||||
onClick={ this.onRotateCounterClockwiseClick }>
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
title={_t("Rotate Right")}
|
||||
onClick={this.onRotateClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
{zoomOutButton}
|
||||
{zoomInButton}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={_t("Download")}
|
||||
onClick={ this.onDownloadClick }>
|
||||
</AccessibleTooltipButton>
|
||||
{contextMenuButton}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_close"
|
||||
title={_t("Close")}
|
||||
onClick={ this.props.onFinished }>
|
||||
</AccessibleTooltipButton>
|
||||
{this.renderContextMenu()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mx_ImageView_image_wrapper"
|
||||
ref={this.imageWrapper}>
|
||||
<img
|
||||
src={this.props.src}
|
||||
title={this.props.name}
|
||||
style={style}
|
||||
ref={this.image}
|
||||
className="mx_ImageView_image"
|
||||
draggable={true}
|
||||
onMouseDown={this.onStartMoving}
|
||||
onMouseMove={this.onMoving}
|
||||
onMouseUp={this.onEndMoving}
|
||||
onMouseLeave={this.onEndMoving}
|
||||
/>
|
||||
</div>
|
||||
</FocusLock>
|
||||
);
|
||||
}
|
||||
}
|
74
src/components/views/elements/InfoTooltip.tsx
Normal file
74
src/components/views/elements/InfoTooltip.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 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 from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Tooltip, {Alignment} from './Tooltip';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface ITooltipProps {
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.InfoTooltip")
|
||||
export default class InfoTooltip extends React.PureComponent<ITooltipProps, IState> {
|
||||
constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {tooltip, children, tooltipClassName} = this.props;
|
||||
const title = _t("Information");
|
||||
|
||||
// Tooltip are forced on the right for a more natural feel to them on info icons
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_InfoTooltip_container"
|
||||
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
alignment={Alignment.Right}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
|
||||
<span className="mx_InfoTooltip_icon" aria-label={title} />
|
||||
{children}
|
||||
{tip}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,20 +15,19 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import createReactClass from 'create-react-class';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'InlineSpinner',
|
||||
|
||||
render: function() {
|
||||
@replaceableComponent("views.elements.InlineSpinner")
|
||||
export default class InlineSpinner extends React.Component {
|
||||
render() {
|
||||
const w = this.props.w || 16;
|
||||
const h = this.props.h || 16;
|
||||
const imgClass = this.props.imgClassName || "";
|
||||
|
||||
let imageSource;
|
||||
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
|
||||
if (SettingsStore.getValue('feature_new_spinner')) {
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
|
@ -45,5 +44,5 @@ export default createReactClass({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
62
src/components/views/elements/InviteReason.tsx
Normal file
62
src/components/views/elements/InviteReason.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React from "react";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.InviteReason")
|
||||
export default class InviteReason extends React.PureComponent<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
// We hide the reason for invitation by default, since it can be a
|
||||
// vector for spam/harassment.
|
||||
hidden: true,
|
||||
};
|
||||
}
|
||||
|
||||
onViewClick = () => {
|
||||
this.setState({
|
||||
hidden: false,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classNames({
|
||||
"mx_InviteReason": true,
|
||||
"mx_InviteReason_hidden": this.state.hidden,
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
<div className="mx_InviteReason_reason">{this.props.reason}</div>
|
||||
<div className="mx_InviteReason_view"
|
||||
onClick={this.onViewClick}
|
||||
>
|
||||
{_t("View message")}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -17,7 +17,9 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from "prop-types";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.LabelledToggleSwitch")
|
||||
export default class LabelledToggleSwitch extends React.Component {
|
||||
static propTypes = {
|
||||
// The value for the toggle switch
|
||||
|
@ -44,8 +46,12 @@ export default class LabelledToggleSwitch extends React.Component {
|
|||
// This is a minimal version of a SettingsFlag
|
||||
|
||||
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
|
||||
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
|
||||
onChange={this.props.onChange} aria-label={this.props.label} />;
|
||||
let secondPart = <ToggleSwitch
|
||||
checked={this.props.value}
|
||||
disabled={this.props.disabled}
|
||||
onChange={this.props.onChange}
|
||||
aria-label={this.props.label}
|
||||
/>;
|
||||
|
||||
if (this.props.toggleInFront) {
|
||||
const temp = firstPart;
|
||||
|
|
|
@ -22,6 +22,7 @@ import * as sdk from '../../../index';
|
|||
import * as languageHandler from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
function languageMatchesSearchQuery(query, language) {
|
||||
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
|
||||
|
@ -29,6 +30,7 @@ function languageMatchesSearchQuery(query, language) {
|
|||
return false;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.LanguageDropdown")
|
||||
export default class LanguageDropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -56,13 +58,8 @@ export default class LanguageDropdown extends React.Component {
|
|||
// If no value is given, we start with the first
|
||||
// country selected, but our parent component
|
||||
// doesn't know this, therefore we do this.
|
||||
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
if (language) {
|
||||
this.props.onOptionChange(language);
|
||||
} else {
|
||||
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
const language = languageHandler.getUserLanguage();
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,10 +97,10 @@ export default class LanguageDropdown extends React.Component {
|
|||
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
let value = null;
|
||||
if (language) {
|
||||
value = this.props.value || language;
|
||||
value = this.props.value || language;
|
||||
} else {
|
||||
language = navigator.language || navigator.userLanguage;
|
||||
value = this.props.value || language;
|
||||
language = navigator.language || navigator.userLanguage;
|
||||
value = this.props.value || language;
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
class ItemRange {
|
||||
constructor(topCount, renderCount, bottomCount) {
|
||||
|
@ -55,6 +56,7 @@ class ItemRange {
|
|||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.LazyRenderList")
|
||||
export default class LazyRenderList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
|
||||
export default class ManageIntegsButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onManageIntegrations = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (!managers.hasManager()) {
|
||||
managers.openNoManagerDialog();
|
||||
} else {
|
||||
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
|
||||
managers.openAll(this.props.room);
|
||||
} else {
|
||||
managers.getPrimaryManager().open(this.props.room);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let integrationsButton = <div />;
|
||||
if (IntegrationManagers.sharedInstance().hasManager()) {
|
||||
integrationsButton = (
|
||||
<AccessibleTooltipButton
|
||||
className='mx_RoomHeader_button mx_RoomHeader_manageIntegsButton'
|
||||
title={_t("Manage Integrations")}
|
||||
onClick={this.onManageIntegrations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return integrationsButton;
|
||||
}
|
||||
}
|
||||
|
||||
ManageIntegsButton.propTypes = {
|
||||
room: PropTypes.object.isRequired,
|
||||
};
|
|
@ -16,44 +16,69 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import React, { ReactChildren } from 'react';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixEvent} from "matrix-js-sdk";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import EventListSummary from "./EventListSummary";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MemberEventListSummary',
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
events: MatrixEvent[];
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength?: number;
|
||||
// The maximum number of avatars to display in the summary
|
||||
avatarsMaxLength?: number;
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold?: number,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded?: boolean,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: ReactChildren;
|
||||
// Called when the MELS expansion is toggled
|
||||
onToggle?(): void,
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
// An array of member events to summarise
|
||||
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: PropTypes.array.isRequired,
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength: PropTypes.number,
|
||||
// The maximum number of avatars to display in the summary
|
||||
avatarsMaxLength: PropTypes.number,
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold: PropTypes.number,
|
||||
// Called when the MELS expansion is toggled
|
||||
onToggle: PropTypes.func,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded: PropTypes.bool,
|
||||
},
|
||||
interface IUserEvents {
|
||||
// The original event
|
||||
mxEvent: MatrixEvent;
|
||||
// The display name of the user (if not, then user ID)
|
||||
displayName: string;
|
||||
// The original index of the event in this.props.events
|
||||
index: number;
|
||||
}
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
summaryLength: 1,
|
||||
threshold: 3,
|
||||
avatarsMaxLength: 5,
|
||||
};
|
||||
},
|
||||
enum TransitionType {
|
||||
Joined = "joined",
|
||||
Left = "left",
|
||||
JoinedAndLeft = "joined_and_left",
|
||||
LeftAndJoined = "left_and_joined",
|
||||
InviteReject = "invite_reject",
|
||||
InviteWithdrawal = "invite_withdrawal",
|
||||
Invited = "invited",
|
||||
Banned = "banned",
|
||||
Unbanned = "unbanned",
|
||||
Kicked = "kicked",
|
||||
ChangedName = "changed_name",
|
||||
ChangedAvatar = "changed_avatar",
|
||||
NoChange = "no_change",
|
||||
}
|
||||
|
||||
shouldComponentUpdate: function(nextProps) {
|
||||
const SEP = ",";
|
||||
|
||||
@replaceableComponent("views.elements.MemberEventListSummary")
|
||||
export default class MemberEventListSummary extends React.Component<IProps> {
|
||||
static defaultProps = {
|
||||
summaryLength: 1,
|
||||
threshold: 3,
|
||||
avatarsMaxLength: 5,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
// Update if
|
||||
// - The number of summarised events has changed
|
||||
// - or if the summary is about to toggle to become collapsed
|
||||
|
@ -62,35 +87,33 @@ export default createReactClass({
|
|||
nextProps.events.length !== this.props.events.length ||
|
||||
nextProps.events.length < this.props.threshold
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
|
||||
* the sequences are ordered by `orderedTransitionSequences`.
|
||||
* @param {object[]} eventAggregates a map of transition sequence to array of user display names
|
||||
* @param {object} eventAggregates a map of transition sequence to array of user display names
|
||||
* or user IDs.
|
||||
* @param {string[]} orderedTransitionSequences an array which is some ordering of
|
||||
* `Object.keys(eventAggregates)`.
|
||||
* @returns {string} the textual summary of the aggregated events that occurred.
|
||||
*/
|
||||
_generateSummary: function(eventAggregates, orderedTransitionSequences) {
|
||||
private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
|
||||
const summaries = orderedTransitionSequences.map((transitions) => {
|
||||
const userNames = eventAggregates[transitions];
|
||||
const nameList = this._renderNameList(userNames);
|
||||
const nameList = this.renderNameList(userNames);
|
||||
|
||||
const splitTransitions = transitions.split(',');
|
||||
const splitTransitions = transitions.split(SEP) as TransitionType[];
|
||||
|
||||
// Some neighbouring transitions are common, so canonicalise some into "pair"
|
||||
// transitions
|
||||
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
|
||||
const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
|
||||
// Transform into consecutive repetitions of the same transition (like 5
|
||||
// consecutive 'joined_and_left's)
|
||||
const coalescedTransitions = this._coalesceRepeatedTransitions(
|
||||
canonicalTransitions,
|
||||
);
|
||||
const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
|
||||
|
||||
const descs = coalescedTransitions.map((t) => {
|
||||
return this._getDescriptionForTransition(
|
||||
return MemberEventListSummary.getDescriptionForTransition(
|
||||
t.transitionType, userNames.length, t.repeats,
|
||||
);
|
||||
});
|
||||
|
@ -105,7 +128,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return summaries.join(", ");
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} users an array of user display names or user IDs.
|
||||
|
@ -113,9 +136,9 @@ export default createReactClass({
|
|||
* more items in `users` than `this.props.summaryLength`, which is the number of names
|
||||
* included before "and [n] others".
|
||||
*/
|
||||
_renderNameList: function(users) {
|
||||
private renderNameList(users: string[]) {
|
||||
return formatCommaSeparatedList(users, this.props.summaryLength);
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise an array of transitions such that some pairs of transitions become
|
||||
|
@ -124,22 +147,22 @@ export default createReactClass({
|
|||
* @param {string[]} transitions an array of transitions.
|
||||
* @returns {string[]} an array of transitions.
|
||||
*/
|
||||
_getCanonicalTransitions: function(transitions) {
|
||||
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
|
||||
const modMap = {
|
||||
'joined': {
|
||||
'after': 'left',
|
||||
'newTransition': 'joined_and_left',
|
||||
[TransitionType.Joined]: {
|
||||
after: TransitionType.Left,
|
||||
newTransition: TransitionType.JoinedAndLeft,
|
||||
},
|
||||
'left': {
|
||||
'after': 'joined',
|
||||
'newTransition': 'left_and_joined',
|
||||
[TransitionType.Left]: {
|
||||
after: TransitionType.Joined,
|
||||
newTransition: TransitionType.LeftAndJoined,
|
||||
},
|
||||
// $currentTransition : {
|
||||
// 'after' : $nextTransition,
|
||||
// 'newTransition' : 'new_transition_type',
|
||||
// },
|
||||
};
|
||||
const res = [];
|
||||
const res: TransitionType[] = [];
|
||||
|
||||
for (let i = 0; i < transitions.length; i++) {
|
||||
const t = transitions[i];
|
||||
|
@ -155,7 +178,7 @@ export default createReactClass({
|
|||
res.push(transition);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an array of transitions into an array of transitions and how many times
|
||||
|
@ -171,8 +194,12 @@ export default createReactClass({
|
|||
* @param {string[]} transitions the array of transitions to transform.
|
||||
* @returns {object[]} an array of coalesced transitions.
|
||||
*/
|
||||
_coalesceRepeatedTransitions: function(transitions) {
|
||||
const res = [];
|
||||
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
|
||||
const res: {
|
||||
transitionType: TransitionType;
|
||||
repeats: number;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < transitions.length; i++) {
|
||||
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
|
||||
res[res.length - 1].repeats += 1;
|
||||
|
@ -184,7 +211,7 @@ export default createReactClass({
|
|||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* For a certain transition, t, describe what happened to the users that
|
||||
|
@ -194,7 +221,7 @@ export default createReactClass({
|
|||
* @param {number} repeats the number of times the transition was repeated in a row.
|
||||
* @returns {string} the written Human Readable equivalent of the transition.
|
||||
*/
|
||||
_getDescriptionForTransition(t, userCount, repeats) {
|
||||
private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
|
||||
// The empty interpolations 'severalUsers' and 'oneUser'
|
||||
// are there only to show translators to non-English languages
|
||||
// that the verb is conjugated to plural or singular Subject.
|
||||
|
@ -222,12 +249,18 @@ export default createReactClass({
|
|||
break;
|
||||
case "invite_reject":
|
||||
res = (userCount > 1)
|
||||
? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats })
|
||||
? _t("%(severalUsers)srejected their invitations %(count)s times", {
|
||||
severalUsers: "",
|
||||
count: repeats,
|
||||
})
|
||||
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
|
||||
break;
|
||||
case "invite_withdrawal":
|
||||
res = (userCount > 1)
|
||||
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats })
|
||||
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
|
||||
severalUsers: "",
|
||||
count: repeats,
|
||||
})
|
||||
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
|
||||
break;
|
||||
case "invited":
|
||||
|
@ -268,11 +301,11 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
}
|
||||
|
||||
_getTransitionSequence: function(events) {
|
||||
return events.map(this._getTransition);
|
||||
},
|
||||
private static getTransitionSequence(events: MatrixEvent[]) {
|
||||
return events.map(MemberEventListSummary.getTransition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label a given membership event, `e`, where `getContent().membership` has
|
||||
|
@ -282,60 +315,60 @@ export default createReactClass({
|
|||
* @returns {string?} the transition type given to this event. This defaults to `null`
|
||||
* if a transition is not recognised.
|
||||
*/
|
||||
_getTransition: function(e) {
|
||||
private static getTransition(e: MatrixEvent): TransitionType {
|
||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||
// Handle 3pid invites the same as invites so they get bundled together
|
||||
if (!isValid3pidInvite(e.mxEvent)) {
|
||||
return 'invite_withdrawal';
|
||||
return TransitionType.InviteWithdrawal;
|
||||
}
|
||||
return 'invited';
|
||||
return TransitionType.Invited;
|
||||
}
|
||||
|
||||
switch (e.mxEvent.getContent().membership) {
|
||||
case 'invite': return 'invited';
|
||||
case 'ban': return 'banned';
|
||||
case 'invite': return TransitionType.Invited;
|
||||
case 'ban': return TransitionType.Banned;
|
||||
case 'join':
|
||||
if (e.mxEvent.getPrevContent().membership === 'join') {
|
||||
if (e.mxEvent.getContent().displayname !==
|
||||
e.mxEvent.getPrevContent().displayname) {
|
||||
return 'changed_name';
|
||||
return TransitionType.ChangedName;
|
||||
} else if (e.mxEvent.getContent().avatar_url !==
|
||||
e.mxEvent.getPrevContent().avatar_url) {
|
||||
return 'changed_avatar';
|
||||
return TransitionType.ChangedAvatar;
|
||||
}
|
||||
// console.log("MELS ignoring duplicate membership join event");
|
||||
return 'no_change';
|
||||
return TransitionType.NoChange;
|
||||
} else {
|
||||
return 'joined';
|
||||
return TransitionType.Joined;
|
||||
}
|
||||
case 'leave':
|
||||
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
|
||||
switch (e.mxEvent.getPrevContent().membership) {
|
||||
case 'invite': return 'invite_reject';
|
||||
default: return 'left';
|
||||
case 'invite': return TransitionType.InviteReject;
|
||||
default: return TransitionType.Left;
|
||||
}
|
||||
}
|
||||
switch (e.mxEvent.getPrevContent().membership) {
|
||||
case 'invite': return 'invite_withdrawal';
|
||||
case 'ban': return 'unbanned';
|
||||
case 'invite': return TransitionType.InviteWithdrawal;
|
||||
case 'ban': return TransitionType.Unbanned;
|
||||
// sender is not target and made the target leave, if not from invite/ban then this is a kick
|
||||
default: return 'kicked';
|
||||
default: return TransitionType.Kicked;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_getAggregate: function(userEvents) {
|
||||
getAggregate(userEvents: Record<string, IUserEvents[]>) {
|
||||
// A map of aggregate type to arrays of display names. Each aggregate type
|
||||
// is a comma-delimited string of transitions, e.g. "joined,left,kicked".
|
||||
// The array of display names is the array of users who went through that
|
||||
// sequence during eventsToRender.
|
||||
const aggregate = {
|
||||
const aggregate: Record<string, string[]> = {
|
||||
// $aggregateType : []:string
|
||||
};
|
||||
// A map of aggregate types to the indices that order them (the index of
|
||||
// the first event for a given transition sequence)
|
||||
const aggregateIndices = {
|
||||
const aggregateIndices: Record<string, number> = {
|
||||
// $aggregateType : int
|
||||
};
|
||||
|
||||
|
@ -345,7 +378,7 @@ export default createReactClass({
|
|||
const firstEvent = userEvents[userId][0];
|
||||
const displayName = firstEvent.displayName;
|
||||
|
||||
const seq = this._getTransitionSequence(userEvents[userId]);
|
||||
const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
|
||||
if (!aggregate[seq]) {
|
||||
aggregate[seq] = [];
|
||||
aggregateIndices[seq] = -1;
|
||||
|
@ -354,8 +387,9 @@ export default createReactClass({
|
|||
aggregate[seq].push(displayName);
|
||||
|
||||
if (aggregateIndices[seq] === -1 ||
|
||||
firstEvent.index < aggregateIndices[seq]) {
|
||||
aggregateIndices[seq] = firstEvent.index;
|
||||
firstEvent.index < aggregateIndices[seq]
|
||||
) {
|
||||
aggregateIndices[seq] = firstEvent.index;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@ -364,30 +398,26 @@ export default createReactClass({
|
|||
names: aggregate,
|
||||
indices: aggregateIndices,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const eventsToRender = this.props.events;
|
||||
|
||||
// Map user IDs to an array of objects:
|
||||
const userEvents = {
|
||||
// $userId : [{
|
||||
// // The original event
|
||||
// mxEvent: e,
|
||||
// // The display name of the user (if not, then user ID)
|
||||
// displayName: e.target.name || userId,
|
||||
// // The original index of the event in this.props.events
|
||||
// index: index,
|
||||
// }]
|
||||
};
|
||||
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
|
||||
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
|
||||
const latestUserAvatarMember = new Map<string, RoomMember>();
|
||||
|
||||
const avatarMembers = [];
|
||||
// Object mapping user IDs to an array of IUserEvents
|
||||
const userEvents: Record<string, IUserEvents[]> = {};
|
||||
eventsToRender.forEach((e, index) => {
|
||||
const userId = e.getStateKey();
|
||||
// Initialise a user's events
|
||||
if (!userEvents[userId]) {
|
||||
userEvents[userId] = [];
|
||||
if (e.target) avatarMembers.push(e.target);
|
||||
}
|
||||
|
||||
if (e.target) {
|
||||
latestUserAvatarMember.set(userId, e.target);
|
||||
}
|
||||
|
||||
let displayName = userId;
|
||||
|
@ -404,21 +434,20 @@ export default createReactClass({
|
|||
});
|
||||
});
|
||||
|
||||
const aggregate = this._getAggregate(userEvents);
|
||||
const aggregate = this.getAggregate(userEvents);
|
||||
|
||||
// Sort types by order of lowest event index within sequence
|
||||
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
|
||||
(seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2],
|
||||
(seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
|
||||
);
|
||||
|
||||
const EventListSummary = sdk.getComponent("views.elements.EventListSummary");
|
||||
return <EventListSummary
|
||||
events={this.props.events}
|
||||
threshold={this.props.threshold}
|
||||
onToggle={this.props.onToggle}
|
||||
startExpanded={this.props.startExpanded}
|
||||
children={this.props.children}
|
||||
summaryMembers={avatarMembers}
|
||||
summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||
},
|
||||
});
|
||||
summaryMembers={[...latestUserAvatarMember.values()]}
|
||||
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||
}
|
||||
}
|
102
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
102
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
Copyright 2020 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, {useContext, useRef, useState} from 'react';
|
||||
import {EventType} from 'matrix-js-sdk/src/@types/event';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {useTimeout} from "../../../hooks/useTimeout";
|
||||
import Analytics from "../../../Analytics";
|
||||
import CountlyAnalytics from '../../../CountlyAnalytics';
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
|
||||
export const AVATAR_SIZE = 52;
|
||||
|
||||
interface IProps {
|
||||
hasAvatar: boolean;
|
||||
noAvatarLabel?: string;
|
||||
hasAvatarLabel?: string;
|
||||
setAvatarUrl(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useTimeout(() => {
|
||||
setShow(true);
|
||||
}, 3000); // show after 3 seconds
|
||||
useTimeout(() => {
|
||||
setShow(false);
|
||||
}, 13000); // hide after being shown for 10 seconds
|
||||
|
||||
const uploadRef = useRef<HTMLInputElement>();
|
||||
|
||||
const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel;
|
||||
|
||||
const {room} = useContext(RoomContext);
|
||||
const canSetAvatar = room?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId());
|
||||
if (!canSetAvatar) return <React.Fragment>{ children }</React.Fragment>;
|
||||
|
||||
const visible = !!label && (hover || show);
|
||||
return <React.Fragment>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadRef}
|
||||
className="mx_MiniAvatarUploader_input"
|
||||
onChange={async (ev) => {
|
||||
if (!ev.target.files?.length) return;
|
||||
setBusy(true);
|
||||
Analytics.trackEvent("mini_avatar", "upload");
|
||||
CountlyAnalytics.instance.track("mini_avatar_upload");
|
||||
const file = ev.target.files[0];
|
||||
const uri = await cli.uploadContent(file);
|
||||
await setAvatarUrl(uri);
|
||||
setBusy(false);
|
||||
}}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
<AccessibleButton
|
||||
className={classNames("mx_MiniAvatarUploader", {
|
||||
mx_MiniAvatarUploader_busy: busy,
|
||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
||||
})}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
uploadRef.current.click();
|
||||
}}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{ children }
|
||||
|
||||
<div className={classNames("mx_Tooltip", {
|
||||
"mx_Tooltip_visible": visible,
|
||||
"mx_Tooltip_invisible": !visible,
|
||||
})}>
|
||||
<div className="mx_Tooltip_chevron" />
|
||||
{ label }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
export default MiniAvatarUploader;
|
|
@ -17,10 +17,14 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {throttle} from "lodash";
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {isNullOrUndefined} from "matrix-js-sdk/src/utils";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -53,12 +57,16 @@ function getOrCreateContainer(containerId) {
|
|||
* children are made visible and are positioned into a div that is given the same
|
||||
* bounding rect as the parent of PE.
|
||||
*/
|
||||
@replaceableComponent("views.elements.PersistedElement")
|
||||
export default class PersistedElement extends React.Component {
|
||||
static propTypes = {
|
||||
// Unique identifier for this PersistedElement instance
|
||||
// Any PersistedElements with the same persistKey will use
|
||||
// the same DOM container.
|
||||
persistKey: PropTypes.string.isRequired,
|
||||
|
||||
// z-index for the element. Defaults to 9.
|
||||
zIndex: PropTypes.number,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -131,6 +139,8 @@ export default class PersistedElement extends React.Component {
|
|||
_onAction(payload) {
|
||||
if (payload.action === 'timeline_resize') {
|
||||
this._repositionChild();
|
||||
} else if (payload.action === 'logout') {
|
||||
PersistedElement.destroyElement(this.props.persistKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -144,9 +154,11 @@ export default class PersistedElement extends React.Component {
|
|||
}
|
||||
|
||||
renderApp() {
|
||||
const content = <div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
|
||||
<div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</MatrixClientContext.Provider>;
|
||||
|
||||
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
|
||||
}
|
||||
|
@ -156,20 +168,23 @@ export default class PersistedElement extends React.Component {
|
|||
child.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateChildPosition(child, parent) {
|
||||
updateChildPosition = throttle((child, parent) => {
|
||||
if (!child || !parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
Object.assign(child.style, {
|
||||
zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex,
|
||||
position: 'absolute',
|
||||
top: parentRect.top + 'px',
|
||||
left: parentRect.left + 'px',
|
||||
width: parentRect.width + 'px',
|
||||
height: parentRect.height + 'px',
|
||||
});
|
||||
}
|
||||
}, 100, {trailing: true, leading: true});
|
||||
|
||||
render() {
|
||||
return <div ref={this.collectChildContainer}></div>;
|
||||
return <div ref={this.collectChildContainer} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const getPersistKey = (appId: string) => 'widget_' + appId;
|
||||
|
|
|
@ -16,53 +16,71 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'PersistentApp',
|
||||
@replaceableComponent("views.elements.PersistentApp")
|
||||
export default class PersistentApp extends React.Component {
|
||||
state = {
|
||||
roomId: RoomViewStore.getRoomId(),
|
||||
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
roomId: RoomViewStore.getRoomId(),
|
||||
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
|
||||
ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate);
|
||||
},
|
||||
MatrixClientPeg.get().on("Room.myMembership", this._onMyMembership);
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
if (this._roomStoreToken) {
|
||||
this._roomStoreToken.remove();
|
||||
}
|
||||
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
|
||||
},
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Room.myMembership", this._onMyMembership);
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomViewStoreUpdate: function(payload) {
|
||||
_onRoomViewStoreUpdate = payload => {
|
||||
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
||||
this.setState({
|
||||
roomId: RoomViewStore.getRoomId(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onActiveWidgetStoreUpdate: function() {
|
||||
_onActiveWidgetStoreUpdate = () => {
|
||||
this.setState({
|
||||
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
_onMyMembership = async (room, membership) => {
|
||||
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
|
||||
if (membership !== "join") {
|
||||
// we're not in the room anymore - delete
|
||||
if (room.roomId === persistentWidgetInRoomId) {
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.state.persistentWidgetId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.persistentWidgetId) {
|
||||
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
|
||||
if (this.state.roomId !== persistentWidgetInRoomId) {
|
||||
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
|
||||
|
||||
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
|
||||
|
||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||
// thus no room is associated anymore.
|
||||
if (!persistentWidgetInRoom) return null;
|
||||
|
||||
const myMembership = persistentWidgetInRoom.getMyMembership();
|
||||
if (this.state.roomId !== persistentWidgetInRoomId && myMembership === "join") {
|
||||
// get the widget data
|
||||
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
|
||||
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
|
||||
|
@ -71,7 +89,6 @@ export default createReactClass({
|
|||
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
|
||||
persistentWidgetInRoomId, appEvent.getId(),
|
||||
);
|
||||
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
|
||||
const AppTile = sdk.getComponent('elements.AppTile');
|
||||
return <AppTile
|
||||
key={app.id}
|
||||
|
@ -79,18 +96,15 @@ export default createReactClass({
|
|||
fullWidth={true}
|
||||
room={persistentWidgetInRoom}
|
||||
userId={MatrixClientPeg.get().credentials.userId}
|
||||
show={true}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
whitelistCapabilities={capWhitelist}
|
||||
showDelete={false}
|
||||
showMinimise={false}
|
||||
miniMode={true}
|
||||
showMenubar={false}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017 - 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.
|
||||
|
@ -16,46 +14,40 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import FlairStore from "../../../stores/FlairStore";
|
||||
import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks";
|
||||
import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
import Tooltip from './Tooltip';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
// For URLs of matrix.to links in the timeline which have been reformatted by
|
||||
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
|
||||
const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+])[^/]*)$/;
|
||||
@replaceableComponent("views.elements.Pill")
|
||||
class Pill extends React.Component {
|
||||
static roomNotifPos(text) {
|
||||
return text.indexOf("@room");
|
||||
}
|
||||
|
||||
const Pill = createReactClass({
|
||||
statics: {
|
||||
isPillUrl: (url) => {
|
||||
return !!getPrimaryPermalinkEntity(url);
|
||||
},
|
||||
isMessagePillUrl: (url) => {
|
||||
return !!REGEX_LOCAL_PERMALINK.exec(url);
|
||||
},
|
||||
roomNotifPos: (text) => {
|
||||
return text.indexOf("@room");
|
||||
},
|
||||
roomNotifLen: () => {
|
||||
return "@room".length;
|
||||
},
|
||||
TYPE_USER_MENTION: 'TYPE_USER_MENTION',
|
||||
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
|
||||
TYPE_GROUP_MENTION: 'TYPE_GROUP_MENTION',
|
||||
TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention
|
||||
},
|
||||
static roomNotifLen() {
|
||||
return "@room".length;
|
||||
}
|
||||
|
||||
props: {
|
||||
static TYPE_USER_MENTION = 'TYPE_USER_MENTION';
|
||||
static TYPE_ROOM_MENTION = 'TYPE_ROOM_MENTION';
|
||||
static TYPE_GROUP_MENTION = 'TYPE_GROUP_MENTION';
|
||||
static TYPE_AT_ROOM_MENTION = 'TYPE_AT_ROOM_MENTION'; // '@room' mention
|
||||
|
||||
static propTypes = {
|
||||
// The Type of this Pill. If url is given, this is auto-detected.
|
||||
type: PropTypes.string,
|
||||
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
|
||||
// The URL to pillify (no validation is done)
|
||||
url: PropTypes.string,
|
||||
// Whether the pill is in a message
|
||||
inMessage: PropTypes.bool,
|
||||
|
@ -65,37 +57,35 @@ const Pill = createReactClass({
|
|||
shouldShowPillAvatar: PropTypes.bool,
|
||||
// Whether to render this pill as if it were highlit by a selection
|
||||
isSelected: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
// ID/alias of the room/user
|
||||
resourceId: null,
|
||||
// Type of pill
|
||||
pillType: null,
|
||||
state = {
|
||||
// ID/alias of the room/user
|
||||
resourceId: null,
|
||||
// Type of pill
|
||||
pillType: null,
|
||||
|
||||
// The member related to the user pill
|
||||
member: null,
|
||||
// The group related to the group pill
|
||||
group: null,
|
||||
// The room related to the room pill
|
||||
room: null,
|
||||
};
|
||||
},
|
||||
// The member related to the user pill
|
||||
member: null,
|
||||
// The group related to the group pill
|
||||
group: null,
|
||||
// The room related to the room pill
|
||||
room: null,
|
||||
// Is the user hovering the pill
|
||||
hover: false,
|
||||
};
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
async UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
let resourceId;
|
||||
let prefix;
|
||||
|
||||
if (nextProps.url) {
|
||||
if (nextProps.inMessage) {
|
||||
// Default to the empty array if no match for simplicity
|
||||
// resource and prefix will be undefined instead of throwing
|
||||
const matrixToMatch = REGEX_LOCAL_PERMALINK.exec(nextProps.url) || [];
|
||||
|
||||
resourceId = matrixToMatch[1]; // The room/user ID
|
||||
prefix = matrixToMatch[2]; // The first character of prefix
|
||||
const parts = parseAppLocalLink(nextProps.url);
|
||||
resourceId = parts.primaryEntityId; // The room/user ID
|
||||
prefix = parts.sigil; // The first character of prefix
|
||||
} else {
|
||||
resourceId = getPrimaryPermalinkEntity(nextProps.url);
|
||||
prefix = resourceId ? resourceId[0] : undefined;
|
||||
|
@ -155,7 +145,7 @@ const Pill = createReactClass({
|
|||
}
|
||||
}
|
||||
this.setState({resourceId, pillType, member, group, room});
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
|
@ -163,13 +153,25 @@ const Pill = createReactClass({
|
|||
|
||||
// eslint-disable-next-line new-cap
|
||||
this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves.
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
doProfileLookup: function(userId, member) {
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
doProfileLookup(userId, member) {
|
||||
MatrixClientPeg.get().getProfileInfo(userId).then((resp) => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
|
@ -188,15 +190,16 @@ const Pill = createReactClass({
|
|||
}).catch((err) => {
|
||||
console.error('Could not retrieve profile data for ' + userId + ':', err);
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onUserPillClicked: function() {
|
||||
onUserPillClicked = () => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.member,
|
||||
});
|
||||
},
|
||||
render: function() {
|
||||
};
|
||||
|
||||
render() {
|
||||
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
|
@ -222,25 +225,25 @@ const Pill = createReactClass({
|
|||
}
|
||||
break;
|
||||
case Pill.TYPE_USER_MENTION: {
|
||||
// If this user is not a member of this room, default to the empty member
|
||||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
member.rawDisplayName = member.rawDisplayName || '';
|
||||
linkText = member.rawDisplayName;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
|
||||
}
|
||||
pillClass = 'mx_UserPill';
|
||||
href = null;
|
||||
onClick = this.onUserPillClicked;
|
||||
// If this user is not a member of this room, default to the empty member
|
||||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
member.rawDisplayName = member.rawDisplayName || '';
|
||||
linkText = member.rawDisplayName;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
|
||||
}
|
||||
pillClass = 'mx_UserPill';
|
||||
href = null;
|
||||
onClick = this.onUserPillClicked;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Pill.TYPE_ROOM_MENTION: {
|
||||
const room = this.state.room;
|
||||
if (room) {
|
||||
linkText = resource;
|
||||
linkText = room.name || resource;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <RoomAvatar room={room} width={16} height={16} aria-hidden="true" />;
|
||||
}
|
||||
|
@ -251,12 +254,12 @@ const Pill = createReactClass({
|
|||
case Pill.TYPE_GROUP_MENTION: {
|
||||
if (this.state.group) {
|
||||
const {avatarUrl, groupId, name} = this.state.group;
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
linkText = groupId;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <BaseAvatar name={name || groupId} width={16} height={16} aria-hidden="true"
|
||||
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 16, 16) : null} />;
|
||||
avatar = <BaseAvatar
|
||||
name={name || groupId} width={16} height={16} aria-hidden="true"
|
||||
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(16) : null} />;
|
||||
}
|
||||
pillClass = 'mx_GroupPill';
|
||||
}
|
||||
|
@ -270,22 +273,43 @@ const Pill = createReactClass({
|
|||
});
|
||||
|
||||
if (this.state.pillType) {
|
||||
const {yOffset} = this.props;
|
||||
|
||||
let tip;
|
||||
if (this.state.hover && resource) {
|
||||
tip = <Tooltip label={resource} yOffset={yOffset} />;
|
||||
}
|
||||
|
||||
return <MatrixClientContext.Provider value={this._matrixClient}>
|
||||
{ this.props.inMessage ?
|
||||
<a className={classes} href={href} onClick={onClick} title={resource} data-offset-key={this.props.offsetKey}>
|
||||
<a
|
||||
className={classes}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
data-offset-key={this.props.offsetKey}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ avatar }
|
||||
{ linkText }
|
||||
{ tip }
|
||||
</a> :
|
||||
<span className={classes} title={resource} data-offset-key={this.props.offsetKey}>
|
||||
<span
|
||||
className={classes}
|
||||
data-offset-key={this.props.offsetKey}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ avatar }
|
||||
{ linkText }
|
||||
{ tip }
|
||||
</span> }
|
||||
</MatrixClientContext.Provider>;
|
||||
} else {
|
||||
// Deliberately render nothing if the URL isn't recognised
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Pill;
|
||||
|
|
|
@ -16,16 +16,15 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as Roles from '../../../Roles';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "./Field";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'PowerSelector',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.PowerSelector")
|
||||
export default class PowerSelector extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
// The maximum value that can be set with the power selector
|
||||
maxValue: PropTypes.number.isRequired,
|
||||
|
@ -42,10 +41,17 @@ export default createReactClass({
|
|||
|
||||
// The name to annotate the selector with
|
||||
label: PropTypes.string,
|
||||
},
|
||||
}
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
static defaultProps = {
|
||||
maxValue: Infinity,
|
||||
usersDefault: 0,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
levelRoleMap: {},
|
||||
// List of power levels to show in the drop-down
|
||||
options: [],
|
||||
|
@ -53,26 +59,20 @@ export default createReactClass({
|
|||
customValue: this.props.value,
|
||||
selectValue: 0,
|
||||
};
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
maxValue: Infinity,
|
||||
usersDefault: 0,
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
// TODO: [REACT-WARNING] Move this to class constructor
|
||||
this._initStateFromProps(this.props);
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
this._initStateFromProps(newProps);
|
||||
},
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this._initStateFromProps(this.props);
|
||||
}
|
||||
|
||||
_initStateFromProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
this._initStateFromProps(newProps);
|
||||
}
|
||||
|
||||
_initStateFromProps(newProps) {
|
||||
// This needs to be done now because levelRoleMap has translated strings
|
||||
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
|
||||
const options = Object.keys(levelRoleMap).filter(level => {
|
||||
|
@ -92,9 +92,9 @@ export default createReactClass({
|
|||
customLevel: newProps.value,
|
||||
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onSelectChange: function(event) {
|
||||
onSelectChange = event => {
|
||||
const isCustom = event.target.value === "SELECT_VALUE_CUSTOM";
|
||||
if (isCustom) {
|
||||
this.setState({custom: true});
|
||||
|
@ -102,20 +102,20 @@ export default createReactClass({
|
|||
this.props.onChange(event.target.value, this.props.powerLevelKey);
|
||||
this.setState({selectValue: event.target.value});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onCustomChange: function(event) {
|
||||
onCustomChange = event => {
|
||||
this.setState({customValue: event.target.value});
|
||||
},
|
||||
};
|
||||
|
||||
onCustomBlur: function(event) {
|
||||
onCustomBlur = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey);
|
||||
},
|
||||
};
|
||||
|
||||
onCustomKeyDown: function(event) {
|
||||
onCustomKeyDown = event => {
|
||||
if (event.key === Key.ENTER) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
@ -127,17 +127,21 @@ export default createReactClass({
|
|||
// handle the onBlur safely.
|
||||
event.target.blur();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let picker;
|
||||
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
|
||||
if (this.state.custom) {
|
||||
picker = (
|
||||
<Field type="number"
|
||||
label={label} max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
|
||||
value={String(this.state.customValue)} disabled={this.props.disabled} />
|
||||
label={label} max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur}
|
||||
onKeyDown={this.onCustomKeyDown}
|
||||
onChange={this.onCustomChange}
|
||||
value={String(this.state.customValue)}
|
||||
disabled={this.props.disabled}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Each level must have a definition in this.state.levelRoleMap
|
||||
|
@ -154,8 +158,9 @@ export default createReactClass({
|
|||
|
||||
picker = (
|
||||
<Field element="select"
|
||||
label={label} onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}>
|
||||
label={label} onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}
|
||||
>
|
||||
{options}
|
||||
</Field>
|
||||
);
|
||||
|
@ -166,5 +171,5 @@ export default createReactClass({
|
|||
{ picker }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ const QRCode: React.FC<IProps> = ({data, className, ...options}) => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [JSON.stringify(data), options]);
|
||||
}, [JSON.stringify(data), options]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <div className={classNames("mx_QRCode", className)}>
|
||||
{ dataUri ? <img src={dataUri} className="mx_VerificationQRCode" alt={_t("QR Code")} /> : <Spinner /> }
|
||||
|
|
|
@ -21,17 +21,22 @@ import {_t} from '../../../languageHandler';
|
|||
import PropTypes from 'prop-types';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import {wantsDateSeparator} from '../../../DateUtils';
|
||||
import {MatrixEvent} from 'matrix-js-sdk';
|
||||
import {MatrixEvent} from 'matrix-js-sdk/src/models/event';
|
||||
import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {LayoutPropType} from "../../../settings/Layout";
|
||||
import escapeHtml from "escape-html";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import {PERMITTED_URL_SCHEMES} from "../../../HtmlUtils";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
||||
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
||||
// be low as each event being loaded (after the first) is triggered by an explicit user action.
|
||||
@replaceableComponent("views.elements.ReplyThread")
|
||||
export default class ReplyThread extends React.Component {
|
||||
static propTypes = {
|
||||
// the latest event in this chain of replies
|
||||
|
@ -40,13 +45,13 @@ export default class ReplyThread extends React.Component {
|
|||
onHeightChanged: PropTypes.func.isRequired,
|
||||
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
|
||||
// Specifies which layout to use.
|
||||
useIRCLayout: PropTypes.bool,
|
||||
layout: LayoutPropType,
|
||||
};
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
// The loaded events to be rendered as linear-replies
|
||||
|
@ -61,6 +66,12 @@ export default class ReplyThread extends React.Component {
|
|||
err: false,
|
||||
};
|
||||
|
||||
this.unmounted = false;
|
||||
this.context.on("Event.replaced", this.onEventReplaced);
|
||||
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
|
||||
this.room.on("Room.redaction", this.onRoomRedaction);
|
||||
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
|
||||
|
||||
this.onQuoteClick = this.onQuoteClick.bind(this);
|
||||
this.canCollapse = this.canCollapse.bind(this);
|
||||
this.collapse = this.collapse.bind(this);
|
||||
|
@ -105,6 +116,9 @@ export default class ReplyThread extends React.Component {
|
|||
{
|
||||
allowedTags: false, // false means allow everything
|
||||
allowedAttributes: false,
|
||||
// we somehow can't allow all schemes, so we allow all that we
|
||||
// know of and mxc (for img tags)
|
||||
allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
|
||||
exclusiveFilter: (frame) => frame.tag === "mx-reply",
|
||||
},
|
||||
);
|
||||
|
@ -198,7 +212,7 @@ export default class ReplyThread extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, useIRCLayout) {
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
|
||||
if (!ReplyThread.getParentEventId(parentEv)) {
|
||||
return <div className="mx_ReplyThread_wrapper_empty" />;
|
||||
}
|
||||
|
@ -207,16 +221,11 @@ export default class ReplyThread extends React.Component {
|
|||
onHeightChanged={onHeightChanged}
|
||||
ref={ref}
|
||||
permalinkCreator={permalinkCreator}
|
||||
useIRCLayout={useIRCLayout}
|
||||
layout={layout}
|
||||
/>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
|
||||
this.room.on("Room.redaction", this.onRoomRedaction);
|
||||
// same event handler as Room.redaction as for both we just do forceUpdate
|
||||
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
|
@ -226,21 +235,36 @@ export default class ReplyThread extends React.Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Event.replaced", this.onEventReplaced);
|
||||
if (this.room) {
|
||||
this.room.removeListener("Room.redaction", this.onRoomRedaction);
|
||||
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
|
||||
}
|
||||
}
|
||||
|
||||
onRoomRedaction = (ev, room) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// If one of the events we are rendering gets redacted, force a re-render
|
||||
if (this.state.events.some(event => event.getId() === ev.getId())) {
|
||||
updateForEventId = (eventId) => {
|
||||
if (this.state.events.some(event => event.getId() === eventId)) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
onEventReplaced = (ev) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// If one of the events we are rendering gets replaced, force a re-render
|
||||
this.updateForEventId(ev.getId());
|
||||
};
|
||||
|
||||
onRoomRedaction = (ev) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
const eventId = ev.getAssociatedId();
|
||||
if (!eventId) return;
|
||||
|
||||
// If one of the events we are rendering gets redacted, force a re-render
|
||||
this.updateForEventId(eventId);
|
||||
};
|
||||
|
||||
async initialize() {
|
||||
const {parentEv} = this.props;
|
||||
// at time of making this component we checked that props.parentEv has a parentEventId
|
||||
|
@ -331,8 +355,14 @@ export default class ReplyThread extends React.Component {
|
|||
{
|
||||
_t('<a>In reply to</a> <pill>', {}, {
|
||||
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
|
||||
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
|
||||
url={makeUserPermalink(ev.getSender())} shouldShowPillAvatar={true} />,
|
||||
'pill': (
|
||||
<Pill
|
||||
type={Pill.TYPE_USER_MENTION}
|
||||
room={room}
|
||||
url={makeUserPermalink(ev.getSender())}
|
||||
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
</blockquote>;
|
||||
|
@ -359,7 +389,9 @@ export default class ReplyThread extends React.Component {
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
isRedacted={ev.isRedacted()}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
replacingEventId={ev.replacingEventId()}
|
||||
/>
|
||||
</blockquote>;
|
||||
});
|
||||
|
|
|
@ -19,8 +19,10 @@ import PropTypes from 'prop-types';
|
|||
import * as sdk from '../../../index';
|
||||
import withValidation from './Validation';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
// Controlled form component wrapping Field for inputting a room alias scoped to a given domain
|
||||
@replaceableComponent("views.elements.RoomAliasField")
|
||||
export default class RoomAliasField extends React.PureComponent {
|
||||
static propTypes = {
|
||||
domain: PropTypes.string.isRequired,
|
||||
|
@ -44,17 +46,18 @@ export default class RoomAliasField extends React.PureComponent {
|
|||
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
|
||||
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
|
||||
return (
|
||||
<Field
|
||||
label={_t("Room address")}
|
||||
className="mx_RoomAliasField"
|
||||
prefixComponent={poundSign}
|
||||
postfixComponent={domain}
|
||||
ref={ref => this._fieldRef = ref}
|
||||
onValidate={this._onValidate}
|
||||
placeholder={_t("e.g. my-room")}
|
||||
onChange={this._onChange}
|
||||
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
|
||||
maxLength={maxlength} />
|
||||
<Field
|
||||
label={_t("Room address")}
|
||||
className="mx_RoomAliasField"
|
||||
prefixComponent={poundSign}
|
||||
postfixComponent={domain}
|
||||
ref={ref => this._fieldRef = ref}
|
||||
onValidate={this._onValidate}
|
||||
placeholder={_t("e.g. my-room")}
|
||||
onChange={this._onChange}
|
||||
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
|
||||
maxLength={maxlength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const RoomDirectoryButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action={Action.ViewRoomDirectory}
|
||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||
label={_t("Room directory")}
|
||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
RoomDirectoryButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RoomDirectoryButton;
|
40
src/components/views/elements/RoomName.tsx
Normal file
40
src/components/views/elements/RoomName.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
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 {useEffect, useState} from "react";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
children?(name: string): JSX.Element;
|
||||
}
|
||||
|
||||
const RoomName = ({ room, children }: IProps): JSX.Element => {
|
||||
const [name, setName] = useState(room?.name);
|
||||
useEventEmitter(room, "Room.name", () => {
|
||||
setName(room?.name);
|
||||
});
|
||||
useEffect(() => {
|
||||
setName(room?.name);
|
||||
}, [room]);
|
||||
|
||||
if (children) return children(name);
|
||||
return name || "";
|
||||
};
|
||||
|
||||
export default RoomName;
|
45
src/components/views/elements/RoomTopic.tsx
Normal file
45
src/components/views/elements/RoomTopic.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
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 React, {useEffect, useState} from "react";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import {linkifyElement} from "../../../HtmlUtils";
|
||||
|
||||
interface IProps {
|
||||
room?: Room;
|
||||
children?(topic: string, ref: (element: HTMLElement) => void): JSX.Element;
|
||||
}
|
||||
|
||||
export const getTopic = room => room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
|
||||
|
||||
const RoomTopic = ({ room, children }: IProps): JSX.Element => {
|
||||
const [topic, setTopic] = useState(getTopic(room));
|
||||
useEventEmitter(room.currentState, "RoomState.events", () => {
|
||||
setTopic(getTopic(room));
|
||||
});
|
||||
useEffect(() => {
|
||||
setTopic(getTopic(room));
|
||||
}, [room]);
|
||||
|
||||
const ref = e => e && linkifyElement(e);
|
||||
if (children) return children(topic, ref);
|
||||
return <span ref={ref}>{ topic }</span>;
|
||||
};
|
||||
|
||||
export default RoomTopic;
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import {_t} from "../../../languageHandler";
|
||||
|
||||
const SSOButton = ({matrixClient, loginType, fragmentAfterLogin, ...props}) => {
|
||||
const onClick = () => {
|
||||
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibleButton {...props} kind="primary" onClick={onClick}>
|
||||
{_t("Sign in with single sign-on")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
SSOButton.propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
|
||||
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
|
||||
fragmentAfterLogin: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SSOButton;
|
150
src/components/views/elements/SSOButtons.tsx
Normal file
150
src/components/views/elements/SSOButtons.tsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
Copyright 2020 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 from "react";
|
||||
import { chunk } from "lodash";
|
||||
import classNames from "classnames";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login";
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
|
||||
interface ISSOButtonProps extends Omit<IProps, "flow"> {
|
||||
idp: IIdentityProvider;
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (brand: IdentityProviderBrand | string) => {
|
||||
switch (brand) {
|
||||
case IdentityProviderBrand.Apple:
|
||||
return require(`../../../../res/img/element-icons/brands/apple.svg`);
|
||||
case IdentityProviderBrand.Facebook:
|
||||
return require(`../../../../res/img/element-icons/brands/facebook.svg`);
|
||||
case IdentityProviderBrand.Github:
|
||||
return require(`../../../../res/img/element-icons/brands/github.svg`);
|
||||
case IdentityProviderBrand.Gitlab:
|
||||
return require(`../../../../res/img/element-icons/brands/gitlab.svg`);
|
||||
case IdentityProviderBrand.Google:
|
||||
return require(`../../../../res/img/element-icons/brands/google.svg`);
|
||||
case IdentityProviderBrand.Twitter:
|
||||
return require(`../../../../res/img/element-icons/brands/twitter.svg`);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SSOButton: React.FC<ISSOButtonProps> = ({
|
||||
matrixClient,
|
||||
loginType,
|
||||
fragmentAfterLogin,
|
||||
idp,
|
||||
primary,
|
||||
mini,
|
||||
...props
|
||||
}) => {
|
||||
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
|
||||
|
||||
const onClick = () => {
|
||||
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id);
|
||||
};
|
||||
|
||||
let icon;
|
||||
let brandClass;
|
||||
const brandIcon = idp ? getIcon(idp.brand) : null;
|
||||
if (brandIcon) {
|
||||
const brandName = idp.brand.split(".").pop();
|
||||
brandClass = `mx_SSOButton_brand_${brandName}`;
|
||||
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
|
||||
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
|
||||
const src = mediaFromMxc(idp.icon, matrixClient).getSquareThumbnailHttp(24);
|
||||
icon = <img src={src} height="24" width="24" alt={idp.name} />;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SSOButton", {
|
||||
[brandClass]: brandClass,
|
||||
mx_SSOButton_mini: mini,
|
||||
mx_SSOButton_default: !idp,
|
||||
mx_SSOButton_primary: primary,
|
||||
});
|
||||
|
||||
if (mini) {
|
||||
// TODO fallback icon
|
||||
return (
|
||||
<AccessibleTooltipButton {...props} title={label} className={classes} onClick={onClick}>
|
||||
{ icon }
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton {...props} className={classes} onClick={onClick}>
|
||||
{ icon }
|
||||
{ label }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
flow: ISSOFlow;
|
||||
loginType?: "sso" | "cas";
|
||||
fragmentAfterLogin?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const MAX_PER_ROW = 6;
|
||||
|
||||
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
|
||||
const providers = flow["org.matrix.msc2858.identity_providers"] || [];
|
||||
if (providers.length < 2) {
|
||||
return <div className="mx_SSOButtons">
|
||||
<SSOButton
|
||||
matrixClient={matrixClient}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
idp={providers[0]}
|
||||
primary={primary}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const rows = Math.ceil(providers.length / MAX_PER_ROW);
|
||||
const size = Math.ceil(providers.length / rows);
|
||||
|
||||
return <div className="mx_SSOButtons">
|
||||
{ chunk(providers, size).map(chunk => (
|
||||
<div key={chunk[0].id} className="mx_SSOButtons_row">
|
||||
{ chunk.map(idp => (
|
||||
<SSOButton
|
||||
key={idp.id}
|
||||
matrixClient={matrixClient}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
idp={idp}
|
||||
mini={true}
|
||||
primary={primary}
|
||||
/>
|
||||
)) }
|
||||
</div>
|
||||
)) }
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default SSOButtons;
|
93
src/components/views/elements/ServerPicker.tsx
Normal file
93
src/components/views/elements/ServerPicker.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
Copyright 2020-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 from 'react';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import TextWithTooltip from "./TextWithTooltip";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from "../../../Modal";
|
||||
import ServerPickerDialog from "../dialogs/ServerPickerDialog";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
dialogTitle?: string;
|
||||
serverConfig: ValidatedServerConfig;
|
||||
onServerConfigChange?(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
const showPickerDialog = (
|
||||
title: string,
|
||||
serverConfig: ValidatedServerConfig,
|
||||
onFinished: (config: ValidatedServerConfig) => void,
|
||||
) => {
|
||||
Modal.createTrackedDialog("Server Picker", "", ServerPickerDialog, { title, serverConfig, onFinished });
|
||||
};
|
||||
|
||||
const onHelpClick = () => {
|
||||
Modal.createTrackedDialog('Custom Server Dialog', '', InfoDialog, {
|
||||
title: _t("Server Options"),
|
||||
description: _t("You can use the custom server options to sign into other Matrix servers by specifying " +
|
||||
"a different homeserver URL. This allows you to use Element with an existing Matrix account on " +
|
||||
"a different homeserver."),
|
||||
button: _t("Dismiss"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
}, "mx_ServerPicker_helpDialog");
|
||||
};
|
||||
|
||||
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
|
||||
let editBtn;
|
||||
if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) {
|
||||
const onClick = () => {
|
||||
showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => {
|
||||
if (config) {
|
||||
onServerConfigChange(config);
|
||||
}
|
||||
});
|
||||
};
|
||||
editBtn = <AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick}>
|
||||
{_t("Edit")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
|
||||
if (serverConfig.hsNameIsDifferent) {
|
||||
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
|
||||
{serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
|
||||
let desc;
|
||||
if (serverConfig.hsName === "matrix.org") {
|
||||
desc = <span className="mx_ServerPicker_desc">
|
||||
{_t("Join millions for free on the largest public server")}
|
||||
</span>;
|
||||
}
|
||||
|
||||
return <div className="mx_ServerPicker">
|
||||
<h3>{title || _t("Homeserver")}</h3>
|
||||
<AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} />
|
||||
<span className="mx_ServerPicker_server">{serverName}</span>
|
||||
{ editBtn }
|
||||
{ desc }
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ServerPicker;
|
|
@ -21,6 +21,7 @@ import { _t } from '../../../languageHandler';
|
|||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import StyledCheckbox from "./StyledCheckbox";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// The setting must be a boolean
|
||||
|
@ -39,6 +40,7 @@ interface IState {
|
|||
value: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.SettingsFlag")
|
||||
export default class SettingsFlag extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// A callback for the selected value
|
||||
|
@ -34,6 +35,7 @@ interface IProps {
|
|||
disabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.Slider")
|
||||
export default class Slider extends React.Component<IProps> {
|
||||
// offset is a terrible inverse approximation.
|
||||
// if the values represents some function f(x) = y where x is the
|
||||
|
@ -45,7 +47,7 @@ export default class Slider extends React.Component<IProps> {
|
|||
// non linear slider.
|
||||
private offset(values: number[], value: number): number {
|
||||
// the index of the first number greater than value.
|
||||
let closest = values.reduce((prev, curr) => {
|
||||
const closest = values.reduce((prev, curr) => {
|
||||
return (value > curr ? prev + 1 : prev);
|
||||
}, 0);
|
||||
|
||||
|
@ -68,17 +70,16 @@ export default class Slider extends React.Component<IProps> {
|
|||
const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue);
|
||||
|
||||
return 100 * (closest - 1 + linearInterpolation) * intervalWidth;
|
||||
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const dots = this.props.values.map(v =>
|
||||
<Dot active={v <= this.props.value}
|
||||
label={this.props.displayFunc(v)}
|
||||
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
|
||||
key={v}
|
||||
disabled={this.props.disabled}
|
||||
/>);
|
||||
const dots = this.props.values.map(v => <Dot
|
||||
active={v <= this.props.value}
|
||||
label={this.props.displayFunc(v)}
|
||||
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
|
||||
key={v}
|
||||
disabled={this.props.disabled}
|
||||
/>);
|
||||
|
||||
let selection = null;
|
||||
|
||||
|
@ -93,7 +94,7 @@ export default class Slider extends React.Component<IProps> {
|
|||
return <div className="mx_Slider">
|
||||
<div>
|
||||
<div className="mx_Slider_bar">
|
||||
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)}/>
|
||||
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)} />
|
||||
{ selection }
|
||||
</div>
|
||||
<div className="mx_Slider_dotContainer">
|
||||
|
|
128
src/components/views/elements/SpellCheckLanguagesDropdown.tsx
Normal file
128
src/components/views/elements/SpellCheckLanguagesDropdown.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 from 'react';
|
||||
|
||||
import Dropdown from "../../views/elements/Dropdown"
|
||||
import * as sdk from '../../../index';
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
function languageMatchesSearchQuery(query, language) {
|
||||
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
|
||||
if (language.value.toUpperCase() === query.toUpperCase()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface SpellCheckLanguagesDropdownIProps {
|
||||
className: string,
|
||||
value: string,
|
||||
onOptionChange(language: string),
|
||||
}
|
||||
|
||||
interface SpellCheckLanguagesDropdownIState {
|
||||
searchQuery: string,
|
||||
languages: any,
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.SpellCheckLanguagesDropdown")
|
||||
export default class SpellCheckLanguagesDropdown extends React.Component<SpellCheckLanguagesDropdownIProps,
|
||||
SpellCheckLanguagesDropdownIState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onSearchChange = this._onSearchChange.bind(this);
|
||||
|
||||
this.state = {
|
||||
searchQuery: '',
|
||||
languages: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const plaf = PlatformPeg.get();
|
||||
if (plaf) {
|
||||
plaf.getAvailableSpellCheckLanguages().then((languages) => {
|
||||
languages.sort(function(a, b) {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
});
|
||||
const langs = [];
|
||||
languages.forEach((language) => {
|
||||
langs.push({
|
||||
label: language,
|
||||
value: language,
|
||||
})
|
||||
})
|
||||
this.setState({languages: langs});
|
||||
}).catch((e) => {
|
||||
this.setState({languages: ['en']});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onSearchChange(search) {
|
||||
this.setState({
|
||||
searchQuery: search,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.languages === null) {
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
let displayedLanguages;
|
||||
if (this.state.searchQuery) {
|
||||
displayedLanguages = this.state.languages.filter((lang) => {
|
||||
return languageMatchesSearchQuery(this.state.searchQuery, lang);
|
||||
});
|
||||
} else {
|
||||
displayedLanguages = this.state.languages;
|
||||
}
|
||||
|
||||
const options = displayedLanguages.map((language) => {
|
||||
return <div key={language.value}>
|
||||
{ language.label }
|
||||
</div>;
|
||||
});
|
||||
|
||||
// default value here too, otherwise we need to handle null / undefined;
|
||||
// values between mounting and the initial value propgating
|
||||
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
let value = null;
|
||||
if (language) {
|
||||
value = this.props.value || language;
|
||||
} else {
|
||||
language = navigator.language || navigator.userLanguage;
|
||||
value = this.props.value || language;
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
id="mx_LanguageDropdown"
|
||||
className={this.props.className}
|
||||
onOptionChange={this.props.onOptionChange}
|
||||
onSearchChange={this._onSearchChange}
|
||||
searchEnabled={true}
|
||||
value={value}
|
||||
label={_t("Language Dropdown")}>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
|
||||
const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
|
||||
let imageSource;
|
||||
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
|
||||
if (SettingsStore.getValue('feature_new_spinner')) {
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
|
|
|
@ -15,7 +15,9 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.Spoiler")
|
||||
export default class Spoiler extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const StartChatButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_create_chat"
|
||||
mouseOverAction={props.callout ? "callout_start_chat" : null}
|
||||
label={_t("Start chat")}
|
||||
iconPath={require("../../../../res/img/icons-people.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
StartChatButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default StartChatButton;
|
|
@ -16,8 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
const CHECK_BOX_SVG = require("../../../../res/img/feather-customised/check.svg");
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
@ -25,6 +24,7 @@ interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||
interface IState {
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.StyledCheckbox")
|
||||
export default class StyledCheckbox extends React.PureComponent<IProps, IState> {
|
||||
private id: string;
|
||||
|
||||
|
@ -39,13 +39,14 @@ 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}>
|
||||
<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={CHECK_BOX_SVG}/>
|
||||
<img src={require("../../../../res/img/feather-customised/check.svg")} />
|
||||
</div>
|
||||
<div>
|
||||
{ this.props.children }
|
||||
|
@ -53,4 +54,4 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
|
|||
</label>
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
outlined?: boolean;
|
||||
|
@ -24,6 +25,7 @@ interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||
interface IState {
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.StyledRadioButton")
|
||||
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
|
||||
public static readonly defaultProps = {
|
||||
className: '',
|
||||
|
|
|
@ -25,6 +25,7 @@ interface IDefinition<T extends string> {
|
|||
disabled?: boolean;
|
||||
label: React.ReactChild;
|
||||
description?: React.ReactChild;
|
||||
checked?: boolean; // If provided it will override the value comparison done in the group
|
||||
}
|
||||
|
||||
interface IProps<T extends string> {
|
||||
|
@ -33,7 +34,7 @@ interface IProps<T extends string> {
|
|||
definitions: IDefinition<T>[];
|
||||
value?: T; // if not provided no options will be selected
|
||||
outlined?: boolean;
|
||||
onChange(newValue: T);
|
||||
onChange(newValue: T): void;
|
||||
}
|
||||
|
||||
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
|
||||
|
@ -46,7 +47,7 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
|
|||
<StyledRadioButton
|
||||
className={classNames(className, d.className)}
|
||||
onChange={_onChange}
|
||||
checked={d.value === value}
|
||||
checked={d.checked !== undefined ? d.checked : d.value === value}
|
||||
name={name}
|
||||
value={d.value}
|
||||
disabled={d.disabled}
|
||||
|
|
|
@ -17,7 +17,9 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {highlightBlock} from 'highlight.js';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.SyntaxHighlight")
|
||||
export default class SyntaxHighlight extends React.Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -27,39 +26,38 @@ import * as FormattingUtils from '../../../utils/FormattingUtils';
|
|||
|
||||
import FlairStore from '../../../stores/FlairStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// a thing to click on for the user to filter the visible rooms in the RoomList to:
|
||||
// - Rooms that are part of the group
|
||||
// - Direct messages with members of the group
|
||||
// with the intention that this could be expanded to arbitrary tags in future.
|
||||
export default createReactClass({
|
||||
displayName: 'TagTile',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.TagTile")
|
||||
export default class TagTile extends React.Component {
|
||||
static propTypes = {
|
||||
// A string tag such as "m.favourite" or a group ID such as "+groupid:domain.bla"
|
||||
// For now, only group IDs are handled.
|
||||
tag: PropTypes.string,
|
||||
contextMenuButtonRef: PropTypes.object,
|
||||
openMenu: PropTypes.func,
|
||||
menuDisplayed: PropTypes.bool,
|
||||
},
|
||||
selected: PropTypes.bool,
|
||||
};
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
// Whether the mouse is over the tile
|
||||
hover: false,
|
||||
// The profile data of the group if this.props.tag is a group ID
|
||||
profile: null,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
// Whether the mouse is over the tile
|
||||
hover: false,
|
||||
// The profile data of the group if this.props.tag is a group ID
|
||||
profile: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
|
@ -69,16 +67,16 @@ export default createReactClass({
|
|||
// New rooms or members may have been added to the group, fetch async
|
||||
this._refreshGroup(this.props.tag);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
if (this.props.tag[0] === '+') {
|
||||
FlairStore.removeListener('updateGroupProfile', this._onFlairStoreUpdated);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onFlairStoreUpdated() {
|
||||
_onFlairStoreUpdated = () => {
|
||||
if (this.unmounted) return;
|
||||
FlairStore.getGroupProfileCached(
|
||||
this.context,
|
||||
|
@ -89,14 +87,14 @@ export default createReactClass({
|
|||
}).catch((err) => {
|
||||
console.warn('Could not fetch group profile for ' + this.props.tag, err);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_refreshGroup(groupId) {
|
||||
GroupStore.refreshGroupRooms(groupId);
|
||||
GroupStore.refreshGroupMembers(groupId);
|
||||
},
|
||||
}
|
||||
|
||||
onClick: function(e) {
|
||||
onClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dis.dispatch({
|
||||
|
@ -109,40 +107,45 @@ export default createReactClass({
|
|||
// New rooms or members may have been added to the group, fetch async
|
||||
this._refreshGroup(this.props.tag);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onMouseOver: function() {
|
||||
onMouseOver = () => {
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) return;
|
||||
this.setState({ hover: true });
|
||||
},
|
||||
};
|
||||
|
||||
onMouseLeave: function() {
|
||||
onMouseLeave = () => {
|
||||
this.setState({ hover: false });
|
||||
},
|
||||
};
|
||||
|
||||
openMenu: function(e) {
|
||||
openMenu = e => {
|
||||
// Prevent the TagTile onClick event firing as well
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) return;
|
||||
this.setState({ hover: false });
|
||||
this.props.openMenu();
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const profile = this.state.profile || {};
|
||||
const name = profile.name || this.props.tag;
|
||||
const avatarHeight = 32;
|
||||
const avatarSize = 32;
|
||||
|
||||
const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp(
|
||||
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
|
||||
) : null;
|
||||
const httpUrl = profile.avatarUrl
|
||||
? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarSize)
|
||||
: null;
|
||||
|
||||
const isPrototype = SettingsStore.getValue("feature_communities_v2_prototypes");
|
||||
const className = classNames({
|
||||
mx_TagTile: true,
|
||||
mx_TagTile_selected: this.props.selected,
|
||||
mx_TagTile_prototype: isPrototype,
|
||||
mx_TagTile_selected: this.props.selected && !isPrototype,
|
||||
mx_TagTile_selected_prototype: this.props.selected && isPrototype,
|
||||
});
|
||||
|
||||
const badge = TagOrderStore.getGroupBadge(this.props.tag);
|
||||
const badge = GroupFilterOrderStore.getGroupBadge(this.props.tag);
|
||||
let badgeElement;
|
||||
if (badge && !this.state.hover && !this.props.menuDisplayed) {
|
||||
const badgeClasses = classNames({
|
||||
|
@ -178,12 +181,12 @@ export default createReactClass({
|
|||
name={name}
|
||||
idName={this.props.tag}
|
||||
url={httpUrl}
|
||||
width={avatarHeight}
|
||||
height={avatarHeight}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
/>
|
||||
{contextButton}
|
||||
{badgeElement}
|
||||
</div>
|
||||
</AccessibleTooltipButton>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,12 +17,15 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.TextWithTooltip")
|
||||
export default class TextWithTooltip extends React.Component {
|
||||
static propTypes = {
|
||||
class: PropTypes.string,
|
||||
tooltipClass: PropTypes.string,
|
||||
tooltip: PropTypes.node.isRequired,
|
||||
tooltipProps: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -44,13 +47,17 @@ export default class TextWithTooltip extends React.Component {
|
|||
render() {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
|
||||
const {class: className, children, tooltip, tooltipClass, tooltipProps, ...props} = this.props;
|
||||
|
||||
return (
|
||||
<span onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={this.props.class}>
|
||||
{this.props.children}
|
||||
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
|
||||
{children}
|
||||
{this.state.hover && <Tooltip
|
||||
label={this.props.tooltip}
|
||||
tooltipClassName={this.props.tooltipClass}
|
||||
className={"mx_TextWithTooltip_tooltip"} /> }
|
||||
{...tooltipProps}
|
||||
label={tooltip}
|
||||
tooltipClassName={tooltipClass}
|
||||
className={"mx_TextWithTooltip_tooltip"}
|
||||
/> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,61 +17,58 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import Tinter from "../../../Tinter";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
const TintableSvg = createReactClass({
|
||||
displayName: 'TintableSvg',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.TintableSvg")
|
||||
class TintableSvg extends React.Component {
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
},
|
||||
};
|
||||
|
||||
statics: {
|
||||
// list of currently mounted TintableSvgs
|
||||
mounts: {},
|
||||
idSequence: 0,
|
||||
},
|
||||
// list of currently mounted TintableSvgs
|
||||
static mounts = {};
|
||||
static idSequence = 0;
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.fixups = [];
|
||||
|
||||
this.id = TintableSvg.idSequence++;
|
||||
TintableSvg.mounts[this.id] = this;
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
delete TintableSvg.mounts[this.id];
|
||||
},
|
||||
}
|
||||
|
||||
tint: function() {
|
||||
tint = () => {
|
||||
// TODO: only bother running this if the global tint settings have changed
|
||||
// since we loaded!
|
||||
Tinter.applySvgFixups(this.fixups);
|
||||
},
|
||||
};
|
||||
|
||||
onLoad: function(event) {
|
||||
onLoad = event => {
|
||||
// console.log("TintableSvg.onLoad for " + this.props.src);
|
||||
this.fixups = Tinter.calcSvgFixups([event.target]);
|
||||
Tinter.applySvgFixups(this.fixups);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
return (
|
||||
<object className={"mx_TintableSvg " + (this.props.className ? this.props.className : "")}
|
||||
type="image/svg+xml"
|
||||
data={this.props.src}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
onLoad={this.onLoad}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
type="image/svg+xml"
|
||||
data={this.props.src}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
onLoad={this.onLoad}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register with the Tinter so that we will be told if the tint changes
|
||||
Tinter.registerTintable(function() {
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TintableSvg from './TintableSvg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
|
||||
export default class TintableSvgButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
let classes = "mx_TintableSvgButton";
|
||||
if (this.props.className) {
|
||||
classes += " " + this.props.className;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
className={classes}>
|
||||
<TintableSvg
|
||||
src={this.props.src}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
></TintableSvg>
|
||||
<AccessibleButton
|
||||
onClick={this.props.onClick}
|
||||
element='span'
|
||||
title={this.props.title}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TintableSvgButton.propTypes = {
|
||||
src: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
width: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
TintableSvgButton.defaultProps = {
|
||||
onClick: function() {},
|
||||
};
|
|
@ -21,9 +21,18 @@ limitations under the License.
|
|||
import React, {Component, CSSProperties} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
const MIN_TOOLTIP_HEIGHT = 25;
|
||||
|
||||
export enum Alignment {
|
||||
Natural, // Pick left or right
|
||||
Left,
|
||||
Right,
|
||||
Top, // Centered
|
||||
Bottom, // Centered
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Class applied to the element used to position the tooltip
|
||||
className?: string;
|
||||
|
@ -35,17 +44,24 @@ interface IProps {
|
|||
visible?: boolean;
|
||||
// the react element to put into the tooltip
|
||||
label: React.ReactNode;
|
||||
forceOnRight?: boolean;
|
||||
alignment?: Alignment; // defaults to Natural
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.Tooltip")
|
||||
export default class Tooltip extends React.Component<IProps> {
|
||||
private tooltipContainer: HTMLElement;
|
||||
private tooltip: void | Element | Component<Element, any, any>;
|
||||
private parent: Element;
|
||||
|
||||
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
|
||||
// so we expose the Alignment options off of us statically.
|
||||
public static readonly Alignment = Alignment;
|
||||
|
||||
public static readonly defaultProps = {
|
||||
visible: true,
|
||||
yOffset: 0,
|
||||
alignment: Alignment.Natural,
|
||||
};
|
||||
|
||||
// Create a wrapper for the tooltip outside the parent and attach it to the body element
|
||||
|
@ -82,11 +98,35 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
|
||||
}
|
||||
|
||||
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
|
||||
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
|
||||
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8;
|
||||
} else {
|
||||
style.left = parentBox.right + window.pageXOffset + 6;
|
||||
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
|
||||
const top = baseTop + offset;
|
||||
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
|
||||
const left = parentBox.right + window.pageXOffset + 6;
|
||||
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
|
||||
switch (this.props.alignment) {
|
||||
case Alignment.Natural:
|
||||
if (parentBox.right > window.innerWidth / 2) {
|
||||
style.right = right;
|
||||
style.top = top;
|
||||
break;
|
||||
}
|
||||
// fall through to Right
|
||||
case Alignment.Right:
|
||||
style.left = left;
|
||||
style.top = top;
|
||||
break;
|
||||
case Alignment.Left:
|
||||
style.right = right;
|
||||
style.top = top;
|
||||
break;
|
||||
case Alignment.Top:
|
||||
style.top = baseTop - 16;
|
||||
style.left = horizontalCenter;
|
||||
break;
|
||||
case Alignment.Bottom:
|
||||
style.top = baseTop + parentBox.height;
|
||||
style.left = horizontalCenter;
|
||||
break;
|
||||
}
|
||||
|
||||
return style;
|
||||
|
|
|
@ -16,31 +16,28 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'TooltipButton',
|
||||
@replaceableComponent("views.elements.TooltipButton")
|
||||
export default class TooltipButton extends React.Component {
|
||||
state = {
|
||||
hover: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
hover: false,
|
||||
};
|
||||
},
|
||||
|
||||
onMouseOver: function() {
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onMouseLeave: function() {
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_TooltipButton_container"
|
||||
|
@ -53,5 +50,5 @@ export default createReactClass({
|
|||
{ tip }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,13 +17,12 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'TruncatedList',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.elements.TruncatedList")
|
||||
export default class TruncatedList extends React.Component {
|
||||
static propTypes = {
|
||||
// The number of elements to show before truncating. If negative, no truncation is done.
|
||||
truncateAt: PropTypes.number,
|
||||
// The className to apply to the wrapping div
|
||||
|
@ -40,20 +39,18 @@ export default createReactClass({
|
|||
// A function which will be invoked when an overflow element is required.
|
||||
// This will be inserted after the children.
|
||||
createOverflowElement: PropTypes.func,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
truncateAt: 2,
|
||||
createOverflowElement: function(overflowCount, totalCount) {
|
||||
return (
|
||||
<div>{ _t("And %(count)s more...", {count: overflowCount}) }</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
static defaultProps ={
|
||||
truncateAt: 2,
|
||||
createOverflowElement(overflowCount, totalCount) {
|
||||
return (
|
||||
<div>{ _t("And %(count)s more...", {count: overflowCount}) }</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
_getChildren: function(start, end) {
|
||||
_getChildren(start, end) {
|
||||
if (this.props.getChildren && this.props.getChildCount) {
|
||||
return this.props.getChildren(start, end);
|
||||
} else {
|
||||
|
@ -64,9 +61,9 @@ export default createReactClass({
|
|||
return c != null;
|
||||
}).slice(start, end);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_getChildCount: function() {
|
||||
_getChildCount() {
|
||||
if (this.props.getChildren && this.props.getChildCount) {
|
||||
return this.props.getChildCount();
|
||||
} else {
|
||||
|
@ -74,9 +71,9 @@ export default createReactClass({
|
|||
return c != null;
|
||||
}).length;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let overflowNode = null;
|
||||
|
||||
const totalChildren = this._getChildCount();
|
||||
|
@ -98,5 +95,5 @@ export default createReactClass({
|
|||
{ overflowNode }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'UserSelector',
|
||||
|
||||
propTypes: {
|
||||
onChange: PropTypes.func,
|
||||
selected_users: PropTypes.arrayOf(PropTypes.string),
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onChange: function() {},
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._user_id_input = createRef();
|
||||
},
|
||||
|
||||
addUser: function(user_id) {
|
||||
if (this.props.selected_users.indexOf(user_id == -1)) {
|
||||
this.props.onChange(this.props.selected_users.concat([user_id]));
|
||||
}
|
||||
},
|
||||
|
||||
removeUser: function(user_id) {
|
||||
this.props.onChange(this.props.selected_users.filter(function(e) {
|
||||
return e != user_id;
|
||||
}));
|
||||
},
|
||||
|
||||
onAddUserId: function() {
|
||||
this.addUser(this._user_id_input.current.value);
|
||||
this._user_id_input.current.value = "";
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const self = this;
|
||||
return (
|
||||
<div>
|
||||
<ul className="mx_UserSelector_UserIdList">
|
||||
{ this.props.selected_users.map(function(user_id, i) {
|
||||
return <li key={user_id}>{ user_id } - <span onClick={function() {self.removeUser(user_id);}}>X</span></li>;
|
||||
}) }
|
||||
</ul>
|
||||
<input type="text" ref={this._user_id_input} defaultValue="" className="mx_UserSelector_userIdInput" placeholder={_t("ex. @bob:example.com")} />
|
||||
<button onClick={this.onAddUserId} className="mx_UserSelector_AddUserId">
|
||||
{ _t("Add User") }
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
87
src/components/views/elements/UserTagTile.tsx
Normal file
87
src/components/views/elements/UserTagTile.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
Copyright 2020 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 from "react";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
import classNames from "classnames";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.UserTagTile")
|
||||
export default class UserTagTile extends React.PureComponent<IProps, IState> {
|
||||
private tagStoreRef: fbEmitter.EventSubscription;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: GroupFilterOrderStore.getSelectedTags().length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.tagStoreRef.remove();
|
||||
}
|
||||
|
||||
private onTagStoreUpdate = () => {
|
||||
const selected = GroupFilterOrderStore.getSelectedTags().length === 0;
|
||||
this.setState({selected});
|
||||
};
|
||||
|
||||
private onTileClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// Deselect all tags
|
||||
defaultDispatcher.dispatch({action: "deselect_tags"});
|
||||
};
|
||||
|
||||
public render() {
|
||||
// XXX: We reuse TagTile classes for ease of demonstration - we should probably generify
|
||||
// TagTile instead if we continue to use this component.
|
||||
const className = classNames({
|
||||
mx_TagTile: true,
|
||||
mx_TagTile_prototype: true,
|
||||
mx_TagTile_selected_prototype: this.state.selected,
|
||||
mx_TagTile_home: true,
|
||||
});
|
||||
return (
|
||||
<AccessibleTooltipButton
|
||||
className={className}
|
||||
onClick={this.onTileClick}
|
||||
title={_t("Home")}
|
||||
>
|
||||
<div className="mx_TagTile_avatar">
|
||||
<div className="mx_TagTile_homeIcon" />
|
||||
</div>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,18 +21,20 @@ import classNames from "classnames";
|
|||
|
||||
type Data = Pick<IFieldState, "value" | "allowEmpty">;
|
||||
|
||||
interface IRule<T> {
|
||||
interface IRule<T, D = void> {
|
||||
key: string;
|
||||
final?: boolean;
|
||||
skip?(this: T, data: Data): boolean;
|
||||
test(this: T, data: Data): boolean | Promise<boolean>;
|
||||
valid?(this: T): string;
|
||||
invalid?(this: T): string;
|
||||
skip?(this: T, data: Data, derivedData: D): boolean;
|
||||
test(this: T, data: Data, derivedData: D): boolean | Promise<boolean>;
|
||||
valid?(this: T, derivedData: D): string;
|
||||
invalid?(this: T, derivedData: D): string;
|
||||
}
|
||||
|
||||
interface IArgs<T> {
|
||||
rules: IRule<T>[];
|
||||
description(this: T): React.ReactChild;
|
||||
interface IArgs<T, D = void> {
|
||||
rules: IRule<T, D>[];
|
||||
description?(this: T, derivedData: D): React.ReactChild;
|
||||
hideDescriptionIfValid?: boolean;
|
||||
deriveData?(data: Data): Promise<D>;
|
||||
}
|
||||
|
||||
export interface IFieldState {
|
||||
|
@ -53,6 +55,12 @@ export interface IValidationResult {
|
|||
* @param {Function} description
|
||||
* Function that returns a string summary of the kind of value that will
|
||||
* meet the validation rules. Shown at the top of the validation feedback.
|
||||
* @param {Boolean} hideDescriptionIfValid
|
||||
* If true, don't show the description if the validation passes validation.
|
||||
* @param {Function} deriveData
|
||||
* Optional function that returns a Promise to an object of generic type D.
|
||||
* The result of this Promise is passed to rule methods `skip`, `test`, `valid`, and `invalid`.
|
||||
* Useful for doing calculations per-value update once rather than in each of the above rule methods.
|
||||
* @param {Object} rules
|
||||
* An array of rules describing how to check to input value. Each rule in an object
|
||||
* and may have the following properties:
|
||||
|
@ -66,7 +74,9 @@ export interface IValidationResult {
|
|||
* A validation function that takes in the current input value and returns
|
||||
* the overall validity and a feedback UI that can be rendered for more detail.
|
||||
*/
|
||||
export default function withValidation<T = undefined>({ description, rules }: IArgs<T>) {
|
||||
export default function withValidation<T = undefined, D = void>({
|
||||
description, hideDescriptionIfValid, deriveData, rules,
|
||||
}: IArgs<T, D>) {
|
||||
return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
|
||||
if (!value && allowEmpty) {
|
||||
return {
|
||||
|
@ -75,6 +85,9 @@ export default function withValidation<T = undefined>({ description, rules }: IA
|
|||
};
|
||||
}
|
||||
|
||||
const data = { value, allowEmpty };
|
||||
const derivedData = deriveData ? await deriveData(data) : undefined;
|
||||
|
||||
const results = [];
|
||||
let valid = true;
|
||||
if (rules && rules.length) {
|
||||
|
@ -87,20 +100,18 @@ export default function withValidation<T = undefined>({ description, rules }: IA
|
|||
continue;
|
||||
}
|
||||
|
||||
const data = { value, allowEmpty };
|
||||
|
||||
if (rule.skip && rule.skip.call(this, data)) {
|
||||
if (rule.skip && rule.skip.call(this, data, derivedData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// We're setting `this` to whichever component holds the validation
|
||||
// function. That allows rules to access the state of the component.
|
||||
const ruleValid = await rule.test.call(this, data);
|
||||
const ruleValid = await rule.test.call(this, data, derivedData);
|
||||
valid = valid && ruleValid;
|
||||
if (ruleValid && rule.valid) {
|
||||
// If the rule's result is valid and has text to show for
|
||||
// the valid state, show it.
|
||||
const text = rule.valid.call(this);
|
||||
const text = rule.valid.call(this, derivedData);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
@ -112,7 +123,7 @@ export default function withValidation<T = undefined>({ description, rules }: IA
|
|||
} else if (!ruleValid && rule.invalid) {
|
||||
// If the rule's result is invalid and has text to show for
|
||||
// the invalid state, show it.
|
||||
const text = rule.invalid.call(this);
|
||||
const text = rule.invalid.call(this, derivedData);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
@ -150,10 +161,10 @@ export default function withValidation<T = undefined>({ description, rules }: IA
|
|||
}
|
||||
|
||||
let summary;
|
||||
if (description) {
|
||||
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);
|
||||
const content = description.call(this, derivedData);
|
||||
summary = <div className="mx_Validation_description">{content}</div>;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue