Merge remote-tracking branch 'upstream/develop' into feature_confetti#14676

This commit is contained in:
Steffen Kolmer 2020-10-19 13:15:33 +02:00
commit c86964cd5e
478 changed files with 21997 additions and 13673 deletions

View file

@ -62,7 +62,8 @@ 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, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"

View file

@ -16,16 +16,13 @@ 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';
export default createReactClass({
displayName: 'RoleButton',
propTypes: {
export default class ActionButton extends React.Component {
static propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
action: PropTypes.string.isRequired,
@ -33,39 +30,35 @@ export default createReactClass({
label: PropTypes.string.isRequired,
iconPath: PropTypes.string,
className: PropTypes.string,
},
};
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;
@ -94,5 +87,5 @@ export default createReactClass({
{ tooltip }
</AccessibleButton>
);
},
});
}
}

View file

@ -17,15 +17,12 @@ 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';
export default createReactClass({
displayName: 'AddressSelector',
propTypes: {
export default class AddressSelector extends React.Component {
static propTypes = {
onSelected: PropTypes.func.isRequired,
// List of the addresses to display
@ -37,90 +34,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) {
// 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 +155,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 +175,5 @@ export default createReactClass({
{ this.createAddressListTiles() }
</div>
);
},
});
}
}

View file

@ -17,7 +17,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 {MatrixClientPeg} from "../../../MatrixClientPeg";
@ -25,25 +24,21 @@ import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js';
export default createReactClass({
displayName: 'AddressTile',
propTypes: {
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;
@ -144,5 +139,5 @@ export default createReactClass({
{ dismiss }
</div>
);
},
});
}
}

View file

@ -18,11 +18,9 @@ limitations under the License.
*/
import url from 'url';
import qs from 'qs';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging';
import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
@ -34,35 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType";
import {Capability} from "../../../widgets/WidgetApi";
import {sleep} from "../../../utils/promise";
import {SettingLevel} from "../../../settings/SettingLevel";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;
/**
* Does template substitution on a URL (or any string). Variables will be
* passed through encodeURIComponent.
* @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { '$bar': 'baz' }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
function uriFromTemplate(uriTemplate, variables) {
let out = uriTemplate;
for (const [key, val] of Object.entries(variables)) {
out = out.replace(
'$' + key, encodeURIComponent(val),
);
}
return out;
}
import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
import {MatrixCapabilities} from "matrix-widget-api";
export default class AppTile extends React.Component {
constructor(props) {
@ -70,11 +49,14 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement
this._persistKey = 'widget_' + this.props.app.id;
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props);
this._onAction = this._onAction.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onRevokeClicked = this._onRevokeClicked.bind(this);
@ -87,7 +69,6 @@ export default class AppTile extends React.Component {
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef();
this._appFrame = createRef();
this._menu_bar = createRef();
}
@ -100,16 +81,16 @@ export default class AppTile extends React.Component {
_getNewState(newProps) {
// This is a function to make the impact of calling SettingsStore slightly less
const hasPermissionToLoad = () => {
if (this._usingLocalWidget()) return true;
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId);
return !!currentlyAllowedWidgets[newProps.app.eventId];
};
const PersistedElement = sdk.getComponent("elements.PersistedElement");
return {
initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
widgetUrl: this._addWurlParams(newProps.app.url),
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
@ -120,43 +101,6 @@ export default class AppTile extends React.Component {
};
}
/**
* Does the widget support a given capability
* @param {string} capability Capability to check for
* @return {Boolean} True if capability supported
*/
_hasCapability(capability) {
return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability);
}
/**
* Add widget instance specific parameters to pass in wUrl
* Properties passed to widget instance:
* - widgetId
* - origin / parent URL
* @param {string} urlString Url string to modify
* @return {string}
* Url string with parameters appended.
* If url can not be parsed, it is returned unmodified.
*/
_addWurlParams(urlString) {
try {
const parsed = new URL(urlString);
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set('widgetId', this.props.app.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
return parsed.toString().replace(/%24/g, '$');
} catch (e) {
console.error("Failed to add widget URL params:", e);
return urlString;
}
}
isMixedContent() {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url);
@ -172,7 +116,7 @@ export default class AppTile extends React.Component {
componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken();
this._startWidget();
}
// Widget action listeners
@ -186,93 +130,45 @@ export default class AppTile extends React.Component {
// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
}
if (this._sgWidget) {
this._sgWidget.stop();
}
}
// TODO: Generify the name of this function. It's not just scalar tokens.
/**
* Adds a scalar token to the widget URL, if required
* Component initialisation is only complete when this function has resolved
*/
setScalarToken() {
if (!WidgetUtils.isScalarUrl(this.props.app.url)) {
console.warn('Widget does not match integration manager, refusing to set auth token', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
_resetWidget(newProps) {
if (this._sgWidget) {
this._sgWidget.stop();
}
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
}
const managers = IntegrationManagers.sharedInstance();
if (!managers.hasManager()) {
console.warn("No integration manager - not setting scalar token", url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
}
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
console.warn('Unknown integration manager, refusing to set auth token', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
}
// Fetch the token before loading the iframe as we need it to mangle the URL
if (!this._scalarClient) {
this._scalarClient = defaultManager.getScalarClient();
}
this._scalarClient.getScalarToken().then((token) => {
// Append scalar_token as a query param if not already present
this._scalarClient.scalarToken = token;
const u = url.parse(this._addWurlParams(this.props.app.url));
const params = qs.parse(u.query);
if (!params.scalar_token) {
params.scalar_token = encodeURIComponent(token);
// u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
u.search = undefined;
u.query = params;
}
this.setState({
error: null,
widgetUrl: u.format(),
initialising: false,
});
// Fetch page title from remote content if not already set
if (!this.state.widgetPageTitle && params.url) {
this._fetchWidgetTitle(params.url);
}
}, (err) => {
console.error("Failed to get scalar_token", err);
this.setState({
error: err.message,
initialising: false,
});
_startWidget() {
this._sgWidget.prepare().then(() => {
this.setState({initialising: false});
});
}
_iframeRefChange = (ref) => {
this.iframe = ref;
if (ref) {
this._sgWidget.start(ref);
} else {
this._resetWidget(this.props);
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps);
// Fetch IM token for new URL if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken();
this._resetWidget(nextProps);
}
}
@ -283,9 +179,9 @@ export default class AppTile extends React.Component {
loading: true,
});
}
// Fetch IM token now that we're showing if we already have permission to load
// Start the widget now that we're showing if we already have permission to load
if (this.state.hasPermissionToLoad) {
this.setScalarToken();
this._startWidget();
}
}
@ -310,35 +206,19 @@ export default class AppTile extends React.Component {
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
// TODO: Open the right manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
this.props.room,
'type_' + this.props.app.type,
this.props.app.id,
);
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(
this.props.room,
'type_' + this.props.app.type,
this.props.app.id,
);
}
WidgetUtils.editWidget(this.props.room, this.props.app);
}
}
_onSnapshotClick() {
console.log("Requesting widget snapshot");
ActiveWidgetStore.getWidgetMessaging(this.props.app.id).getScreenshot()
.catch((err) => {
console.error("Failed to get screenshot", err);
})
.then((screenshot) => {
dis.dispatch({
action: 'picture_snapshot',
file: screenshot,
}, true);
this._sgWidget.widgetApi.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
}
/**
@ -346,35 +226,28 @@ export default class AppTile extends React.Component {
* @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/
_endWidgetActions() {
let terminationPromise;
if (this._hasCapability(Capability.ReceiveTerminate)) {
// Wait for widget to terminate within a timeout
const timeout = 2000;
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
} else {
terminationPromise = Promise.resolve();
async _endWidgetActions() { // widget migration dev note: async to maintain signature
// HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
if (this.iframe) {
// In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away.
// This is relative to where the Element instance is located.
this.iframe.src = 'about:blank';
}
return terminationPromise.finally(() => {
// HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
if (this._appFrame.current) {
// In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away.
// This is relative to where the Element instance is located.
this._appFrame.current.src = 'about:blank';
}
if (WidgetType.JITSI.matches(this.props.app.type)) {
dis.dispatch({action: 'hangup_conference'});
}
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
});
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop({forceDestroy: true});
}
/* If user has permission to modify widgets, delete the widget,
@ -419,101 +292,47 @@ export default class AppTile extends React.Component {
}
}
_onUnpinClicked = () => {
WidgetStore.instance.unpinWidget(this.props.app.id);
}
_onRevokeClicked() {
console.info("Revoke widget permissions - %s", this.props.app.id);
this._revokeWidgetPermission();
}
/**
* Called when widget iframe has finished loading
*/
_onLoaded() {
// Destroy the old widget messaging before starting it back up again. Some widgets
// have startup routines that run when they are loaded, so we just need to reinitialize
// the messaging for them.
ActiveWidgetStore.delWidgetMessaging(this.props.app.id);
this._setupWidgetMessaging();
ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId);
_onWidgetPrepared = () => {
this.setState({loading: false});
}
};
_setupWidgetMessaging() {
// FIXME: There's probably no reason to do this here: it should probably be done entirely
// in ActiveWidgetStore.
const widgetMessaging = new WidgetMessaging(
this.props.app.id,
this.props.app.url,
this._getRenderedUrl(),
this.props.userWidget,
this._appFrame.current.contentWindow,
);
ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging);
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities);
requestedCapabilities = requestedCapabilities || [];
// Allow whitelisted capabilities
let requestedWhitelistCapabilies = [];
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
return this.indexOf(e)>=0;
}, this.props.whitelistCapabilities);
if (requestedWhitelistCapabilies.length > 0 ) {
console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` +
requestedWhitelistCapabilies,
);
}
}
// TODO -- Add UI to warn about and optionally allow requested capabilities
ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies);
if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
}
// We only tell Jitsi widgets that we're ready because they're realistically the only ones
// using this custom extension to the widget API.
if (WidgetType.JITSI.matches(this.props.app.type)) {
widgetMessaging.flagReadyToContinue();
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
});
}
_onWidgetReady = () => {
if (WidgetType.JITSI.matches(this.props.app.type)) {
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
};
_onAction(payload) {
if (payload.widgetId === this.props.app.id) {
switch (payload.action) {
case 'm.sticker':
if (this._hasCapability('m.sticker')) {
dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else {
console.warn('Ignoring sticker message. Invalid capability');
}
break;
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else {
console.warn('Ignoring sticker message. Invalid capability');
}
break;
case Action.AppTileDelete:
this._onDeleteClick();
break;
case Action.AppTileRevoke:
this._onRevokeClicked();
break;
}
}
}
/**
* Set remote content title on AppTile
* @param {string} url Url to check for title
*/
_fetchWidgetTitle(url) {
this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
if (widgetPageTitle) {
this.setState({widgetPageTitle: widgetPageTitle});
}
}, (err) =>{
console.error("Failed to get page title", err);
});
}
_grantWidgetPermission() {
const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId);
@ -523,7 +342,7 @@ export default class AppTile extends React.Component {
this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to
this.setScalarToken();
this._startWidget();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
@ -542,6 +361,7 @@ export default class AppTile extends React.Component {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
@ -571,6 +391,9 @@ export default class AppTile extends React.Component {
if (this.props.show) {
// if we were being shown, end the widget as we're about to be minimized.
this._endWidgetActions();
} else {
// restart the widget actions
this._resetWidget(this.props);
}
dis.dispatch({
action: 'appsDrawer',
@ -580,94 +403,19 @@ export default class AppTile extends React.Component {
}
/**
* Replace the widget template variables in a url with their values
*
* @param {string} u The URL with template variables
* @param {string} widgetType The widget's type
*
* @returns {string} url with temlate variables replaced
* Whether we're using a local version of the widget rather than loading the
* actual widget URL
* @returns {bool} true If using a local version of the widget
*/
_templatedUrl(u, widgetType: string) {
const targetData = {};
if (WidgetType.JITSI.matches(widgetType)) {
targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded
}
const myUserId = MatrixClientPeg.get().credentials.userId;
const myUser = MatrixClientPeg.get().getUser(myUserId);
const vars = Object.assign(targetData, this.props.app.data, {
'matrix_user_id': myUserId,
'matrix_room_id': this.props.room.roomId,
'matrix_display_name': myUser ? myUser.displayName : myUserId,
'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '',
// TODO: Namespace themes through some standard
'theme': SettingsStore.getValue("theme"),
});
if (vars.conferenceId === undefined) {
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
const parsedUrl = new URL(this.props.app.url);
vars.conferenceId = parsedUrl.searchParams.get("confId");
}
return uriFromTemplate(u, vars);
}
/**
* Get the URL used in the iframe
* In cases where we supply our own UI for a widget, this is an internal
* URL different to the one used if the widget is popped out to a separate
* tab / browser
*
* @returns {string} url
*/
_getRenderedUrl() {
let url;
if (WidgetType.JITSI.matches(this.props.app.type)) {
console.log("Replacing Jitsi widget URL with local wrapper");
url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true});
url = this._addWurlParams(url);
} else {
url = this._getSafeUrl(this.state.widgetUrl);
}
return this._templatedUrl(url, this.props.app.type);
}
_getPopoutUrl() {
if (WidgetType.JITSI.matches(this.props.app.type)) {
return this._templatedUrl(
WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: false}),
this.props.app.type,
);
} else {
// use app.url, not state.widgetUrl, because we want the one without
// the wURL params for the popped-out version.
return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type);
}
}
_getSafeUrl(u) {
const parsedWidgetUrl = url.parse(u, true);
if (ENABLE_REACT_PERF) {
parsedWidgetUrl.search = null;
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
// Replace all the dollar signs back to dollar signs as they don't affect HTTP at all.
// We also need the dollar signs in-tact for variable substitution.
return safeWidgetUrl.replace(/%24/g, '$');
_usingLocalWidget() {
return WidgetType.JITSI.matches(this.props.app.type);
}
_getTileTitle() {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
title = this.state.widgetPageTitle;
}
@ -690,9 +438,9 @@ export default class AppTile extends React.Component {
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
this._endWidgetActions().then(() => {
if (this._appFrame.current) {
if (this.iframe) {
// Reload iframe
this._appFrame.current.src = this._getRenderedUrl();
this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
@ -700,13 +448,13 @@ export default class AppTile extends React.Component {
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click();
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
}
_onReloadWidgetClick() {
// Reload iframe in this way to avoid cross-origin restrictions
// eslint-disable-next-line no-self-assign
this._appFrame.current.src = this._appFrame.current.src;
this.iframe.src = this.iframe.src;
}
_onContextMenuClick = () => {
@ -735,7 +483,7 @@ export default class AppTile extends React.Component {
// Additional iframe feature pemissions
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media; autoplay;";
const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture;";
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
@ -752,7 +500,7 @@ export default class AppTile extends React.Component {
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl}
url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
@ -777,11 +525,11 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._appFrame}
src={this._getRenderedUrl()}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
allowFullScreen={true}
sandbox={sandboxFlags}
onLoad={this._onLoaded} />
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
@ -804,14 +552,16 @@ export default class AppTile extends React.Component {
const showMinimiseButton = this.props.showMinimise && this.props.show;
const showMaximiseButton = this.props.showMinimise && !this.props.show;
let appTileClass;
let appTileClasses;
if (this.props.miniMode) {
appTileClass = 'mx_AppTile_mini';
appTileClasses = {mx_AppTile_mini: true};
} else if (this.props.fullWidth) {
appTileClass = 'mx_AppTileFullWidth';
appTileClasses = {mx_AppTileFullWidth: true};
} else {
appTileClass = 'mx_AppTile';
appTileClasses = {mx_AppTile: true};
}
appTileClasses.mx_AppTile_minimised = !this.props.show;
appTileClasses = classNames(appTileClasses);
const menuBarClasses = classNames({
mx_AppTileMenuBar: true,
@ -823,14 +573,18 @@ export default class AppTile extends React.Component {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}>
<WidgetContextMenu
onUnpinClicked={
ActiveWidgetStore.getWidgetPersistence(this.props.app.id) ? null : this._onUnpinClicked
}
onRevokeClicked={this._onRevokeClicked}
onEditClicked={showEditButton ? this._onEditClick : undefined}
onDeleteClicked={showDeleteButton ? this._onDeleteClick : undefined}
@ -843,20 +597,20 @@ export default class AppTile extends React.Component {
}
return <React.Fragment>
<div className={appTileClass} id={this.props.app.id}>
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
{ /* Minimise widget */ }
{ showMinimiseButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_minimise"
title={_t('Minimize apps')}
title={_t('Minimize widget')}
onClick={this._onMinimiseClick}
/> }
{ /* Maximise widget */ }
{ showMaximiseButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
title={_t('Maximize apps')}
title={_t('Maximize widget')}
onClick={this._onMinimiseClick}
/> }
{ /* Title */ }
@ -930,9 +684,6 @@ AppTile.propTypes = {
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array,
// Optional function to be called on widget capability request
// Called with an array of the requested capabilities
onCapabilityRequest: PropTypes.func,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
};

View 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>
);
}

View file

@ -18,16 +18,13 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
/**
* Basic container for buttons in modal dialogs.
*/
export default createReactClass({
displayName: "DialogButtons",
propTypes: {
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 +54,18 @@ export default createReactClass({
// disables only the primary button
primaryDisabled: PropTypes.bool,
},
};
getDefaultProps: function() {
return {
hasCancel: true,
disabled: false,
};
},
static defaultProps = {
hasCancel: true,
disabled: false,
};
_onCancelClick: function() {
_onCancelClick = () => {
this.props.onCancel();
},
};
render: function() {
render() {
let primaryButtonClassName = "mx_Dialog_primary";
if (this.props.primaryButtonClass) {
primaryButtonClassName += " " + this.props.primaryButtonClass;
@ -104,5 +99,5 @@ export default createReactClass({
</button>
</div>
);
},
});
}
}

View file

@ -34,7 +34,6 @@ export interface ILocationState {
}
export default class Draggable extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
@ -77,5 +76,4 @@ export default class Draggable extends React.Component<IProps, IState> {
render() {
return <div className={this.props.className} onMouseDown={this.onMouseDown.bind(this)} />;
}
}
}

View file

@ -17,13 +17,10 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {Key} from "../../../Keyboard";
export default createReactClass({
displayName: 'EditableText',
propTypes: {
export default class EditableText extends React.Component {
static propTypes = {
onValueChanged: PropTypes.func,
initialValue: PropTypes.string,
label: PropTypes.string,
@ -36,60 +33,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 +96,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 +138,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 +156,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 +179,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,13 +204,15 @@ 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 }
@ -234,5 +229,5 @@ export default createReactClass({
}
return editableEl;
},
});
}
}

View file

@ -20,6 +20,7 @@ import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg';
import Modal from '../../../Modal';
import SdkConfig from "../../../SdkConfig";
/**
* This error boundary component can be used to wrap large content areas and
@ -73,9 +74,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 +96,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>

View file

@ -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;

View file

@ -21,6 +21,8 @@ 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 {UIFeature} from "../../../settings/UIFeature";
interface IProps {
/**
@ -39,11 +41,13 @@ interface IProps {
className: string;
}
/* eslint-disable camelcase */
interface IState {
userId: string;
displayname: string;
avatar_url: string;
}
/* eslint-enable camelcase */
const AVATAR_SIZE = 32;
@ -63,48 +67,50 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
const client = MatrixClientPeg.get();
const userId = client.getUserId();
const profileInfo = await client.getProfileInfo(userId);
const avatar_url = Avatar.avatarUrlForUser(
const avatarUrl = Avatar.avatarUrlForUser(
{avatarUrl: profileInfo.avatar_url},
AVATAR_SIZE, AVATAR_SIZE, "crop");
this.setState({
userId,
displayname: profileInfo.displayname,
avatar_url,
avatar_url: avatarUrl,
});
}
private fakeEvent({userId, displayname, avatar_url}: IState) {
private fakeEvent({userId, displayname, avatar_url: avatarUrl}: 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: userId,
content: {
"m.new_content": {
msgtype: "m.text",
body: this.props.message,
displayname: displayname,
avatar_url: avatarUrl,
},
"unsigned": {
"age": 97
},
"event_id": "$9999999999999999999999999999999999999999999",
"room_id": "!999999999999999999:matrix.org"
}`));
msgtype: "m.text",
body: this.props.message,
displayname: displayname,
avatar_url: 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,
getAvatarUrl: (..._) => {
return avatar_url;
return avatarUrl;
},
};
@ -114,16 +120,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.useIRCLayout,
"mx_GroupLayout": !this.props.useIRCLayout,
});
return <div className={className}>
<EventTile mxEvent={event} useIRCLayout={this.props.useIRCLayout} />
<EventTile
mxEvent={event}
useIRCLayout={this.props.useIRCLayout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
</div>;
}
}

View file

@ -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.
@ -198,11 +198,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
}
}
public render() {
const {
element, prefixComponent, postfixComponent, className, onValidate, children,
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
// Set some defaults for the <input> element

View file

@ -78,7 +78,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,
);
}
}

View file

@ -0,0 +1,72 @@
/*
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 from './Tooltip';
import { _t } from "../../../languageHandler";
interface ITooltipProps {
tooltip?: React.ReactNode;
tooltipClassName?: string;
}
interface IState {
hover: boolean;
}
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}
forceOnRight={true}
/> : <div />;
return (
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
<span className="mx_InfoTooltip_icon" aria-label={title} />
{children}
{tip}
</div>
);
}
}

View file

@ -15,14 +15,11 @@ limitations under the License.
*/
import React from "react";
import createReactClass from 'create-react-class';
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
export default createReactClass({
displayName: 'InlineSpinner',
render: function() {
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 || "";
@ -45,5 +42,5 @@ export default createReactClass({
/>
</div>
);
},
});
}
}

View file

@ -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.getValue("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,
};

View file

@ -16,44 +16,67 @@ 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";
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 = ",";
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 +85,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 +126,7 @@ export default createReactClass({
}
return summaries.join(", ");
},
}
/**
* @param {string[]} users an array of user display names or user IDs.
@ -113,9 +134,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 +145,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 +176,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 +192,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 +209,7 @@ export default createReactClass({
}
}
return res;
},
}
/**
* For a certain transition, t, describe what happened to the users that
@ -194,7 +219,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 +247,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 +299,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 +313,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 +376,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 +385,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 +396,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 +432,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)} />;
}
}

View file

@ -17,7 +17,7 @@ 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';
@ -156,7 +156,7 @@ 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();
@ -167,9 +167,9 @@ export default class PersistedElement extends React.Component {
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} />;
}
}

View file

@ -16,53 +16,53 @@ 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';
export default createReactClass({
displayName: '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);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
},
}
_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() {
render() {
if (this.state.persistentWidgetId) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (this.state.roomId !== 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;
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
@ -81,16 +81,17 @@ export default createReactClass({
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;
},
});
}
}

View file

@ -16,7 +16,6 @@ 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';
@ -30,29 +29,31 @@ import {Action} from "../../../dispatcher/actions";
// 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)\/(([#!@+])[^/]*)$/;
const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+]).*?)(?=\/|\?|$)/;
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
},
class Pill extends React.Component {
static isPillUrl(url) {
return !!getPrimaryPermalinkEntity(url);
}
props: {
static isMessagePillUrl(url) {
return !!REGEX_LOCAL_PERMALINK.exec(url);
}
static roomNotifPos(text) {
return text.indexOf("@room");
}
static roomNotifLen() {
return "@room".length;
}
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)
@ -65,25 +66,24 @@ 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,
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
async UNSAFE_componentWillReceiveProps(nextProps) {
let resourceId;
let prefix;
@ -155,7 +155,7 @@ const Pill = createReactClass({
}
}
this.setState({resourceId, pillType, member, group, room});
},
}
componentDidMount() {
this._unmounted = false;
@ -163,13 +163,13 @@ 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) {
doProfileLookup(userId, member) {
MatrixClientPeg.get().getProfileInfo(userId).then((resp) => {
if (this._unmounted) {
return;
@ -188,15 +188,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');
@ -285,7 +286,7 @@ const Pill = createReactClass({
// Deliberately render nothing if the URL isn't recognised
return null;
}
},
});
}
}
export default Pill;

View file

@ -16,16 +16,13 @@ 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";
export default createReactClass({
displayName: 'PowerSelector',
propTypes: {
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 +39,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 +57,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 +90,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 +100,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,9 +125,9 @@ 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) {
@ -166,5 +164,5 @@ export default createReactClass({
{ picker }
</div>
);
},
});
}
}

View file

@ -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 /> }

View file

@ -28,6 +28,8 @@ 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";
// 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
@ -45,8 +47,8 @@ export default class ReplyThread extends React.Component {
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 +63,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 +113,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",
},
);
@ -212,11 +223,6 @@ export default class ReplyThread extends React.Component {
}
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 +232,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 +352,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>;
@ -360,6 +387,8 @@ export default class ReplyThread extends React.Component {
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
useIRCLayout={this.props.useIRCLayout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
/>
</blockquote>;
});

View file

@ -45,7 +45,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 +68,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 +92,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">

View file

@ -17,8 +17,6 @@ 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");
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
}
@ -39,13 +37,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 +52,4 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
</label>
</span>;
}
}
}

View file

@ -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,35 @@ 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";
// 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: {
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 +64,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 +84,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,25 +104,27 @@ 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;
@ -137,12 +134,15 @@ export default createReactClass({
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : 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({
@ -185,5 +185,5 @@ export default createReactClass({
{badgeElement}
</div>
</AccessibleTooltipButton>;
},
});
}
}

View file

@ -17,49 +17,44 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import Tinter from "../../../Tinter";
const TintableSvg = createReactClass({
displayName: 'TintableSvg',
propTypes: {
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"
@ -70,8 +65,8 @@ const TintableSvg = createReactClass({
tabIndex="-1"
/>
);
},
});
}
}
// Register with the Tinter so that we will be told if the tint changes
Tinter.registerTintable(function() {

View file

@ -16,31 +16,26 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
export default createReactClass({
displayName: '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 +48,5 @@ export default createReactClass({
{ tip }
</div>
);
},
});
}
}

View file

@ -17,13 +17,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
export default createReactClass({
displayName: 'TruncatedList',
propTypes: {
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 +37,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 +59,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 +69,9 @@ export default createReactClass({
return c != null;
}).length;
}
},
}
render: function() {
render() {
let overflowNode = null;
const totalChildren = this._getChildCount();
@ -98,5 +93,5 @@ export default createReactClass({
{ overflowNode }
</div>
);
},
});
}
}

View file

@ -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>
);
},
});

View file

@ -0,0 +1,85 @@
/*
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";
interface IProps {
}
interface IState {
selected: boolean;
}
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>
);
}
}

View file

@ -21,18 +21,19 @@ 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;
deriveData?(data: Data): Promise<D>;
}
export interface IFieldState {
@ -53,6 +54,10 @@ 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 {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 +71,7 @@ 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, deriveData, rules }: IArgs<T, D>) {
return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
if (!value && allowEmpty) {
return {
@ -75,6 +80,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 +95,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 +118,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;
}
@ -153,7 +159,7 @@ export default function withValidation<T = undefined>({ description, rules }: IA
if (description) {
// 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>;
}