Merge branch 'develop' into matthew/e2e_backups

This commit is contained in:
Matthew Hodgson 2018-05-28 00:46:05 +01:00
commit 25216d4660
824 changed files with 43369 additions and 3899 deletions

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import AvatarLogic from '../../../Avatar';
import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
@ -36,6 +38,10 @@ module.exports = React.createClass({
defaultToInitialLetter: PropTypes.bool, // true to add default url
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getDefaultProps: function() {
return {
width: 40,
@ -49,6 +55,16 @@ module.exports = React.createClass({
return this._getState(this.props);
},
componentWillMount() {
this.unmounted = false;
this.context.matrixClient.on('sync', this.onClientSync);
},
componentWillUnmount() {
this.unmounted = true;
this.context.matrixClient.removeListener('sync', this.onClientSync);
},
componentWillReceiveProps: function(nextProps) {
// work out if we need to call setState (if the image URLs array has changed)
const newState = this._getState(nextProps);
@ -67,6 +83,23 @@ module.exports = React.createClass({
}
},
onClientSync: function(syncState, prevState) {
if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected &&
// Did we fall back?
this.state.urlsIndex > 0
) {
// Start from the highest priority URL again
this.setState({
urlsIndex: 0,
});
}
},
_getState: function(props) {
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, props.urls, default image ]

View file

@ -1,169 +0,0 @@
/*
Copyright 2017 Travis Ralston
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.
*/
'use strict';
import React from "react";
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg";
import AccessibleButton from '../elements/AccessibleButton';
import Presence from "../../../Presence";
import dispatcher from "../../../dispatcher";
import * as ContextualMenu from "../../structures/ContextualMenu";
import SettingsStore from "../../../settings/SettingsStore";
// This is an avatar with presence information and controls on it.
module.exports = React.createClass({
displayName: 'MemberPresenceAvatar',
propTypes: {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
},
getDefaultProps: function() {
return {
width: 40,
height: 40,
resizeMethod: 'crop',
};
},
getInitialState: function() {
let presenceState = null;
let presenceMessage = null;
// RoomMembers do not necessarily have a user.
if (this.props.member.user) {
presenceState = this.props.member.user.presence;
presenceMessage = this.props.member.user.presenceStatusMsg;
}
return {
status: presenceState,
message: presenceMessage,
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("User.presence", this.onUserPresence);
this.dispatcherRef = dispatcher.register(this.onAction);
},
componentWillUnmount: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("User.presence", this.onUserPresence);
}
dispatcher.unregister(this.dispatcherRef);
},
onAction: function(payload) {
if (payload.action !== "self_presence_updated") return;
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: payload.statusInfo.presence,
message: payload.statusInfo.status_msg,
});
},
onUserPresence: function(event, user) {
if (user.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: user.presence,
message: user.presenceStatusMsg,
});
},
onStatusChange: function(newStatus) {
Presence.stopMaintainingStatus();
if (newStatus === "online") {
Presence.setState(newStatus);
} else Presence.setState(newStatus, null, true);
},
onClick: function(e) {
const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu');
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
ContextualMenu.createMenu(PresenceContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 125,
currentStatus: this.state.status,
onChange: this.onStatusChange,
});
e.stopPropagation();
// XXX NB the following assumes that user is non-null, which is not valid
// const presenceState = this.props.member.user.presence;
// const presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
// const presenceLastTs = this.props.member.user.lastPresenceTs;
// const presenceCurrentlyActive = this.props.member.user.currentlyActive;
// const presenceMessage = this.props.member.user.presenceStatusMsg;
},
render: function() {
const MemberAvatar = sdk.getComponent("avatars.MemberAvatar");
let onClickFn = null;
if (this.props.member.userId === MatrixClientPeg.get().getUserId()) {
onClickFn = this.onClick;
}
const avatarNode = (
<MemberAvatar member={this.props.member} width={this.props.width} height={this.props.height}
resizeMethod={this.props.resizeMethod} />
);
let statusNode = (
<span className={"mx_MemberPresenceAvatar_status mx_MemberPresenceAvatar_status_" + this.state.status} />
);
// LABS: Disable presence management functions for now
// Also disable the presence information if there's no status information
if (!SettingsStore.isFeatureEnabled("feature_presence_management") || !this.state.status) {
statusNode = null;
onClickFn = null;
}
let avatar = (
<div className="mx_MemberPresenceAvatar">
{ avatarNode }
{ statusNode }
</div>
);
if (onClickFn) {
avatar = (
<AccessibleButton onClick={onClickFn} className="mx_MemberPresenceAvatar" element="div">
{ avatarNode }
{ statusNode }
</AccessibleButton>
);
}
return avatar;
},
});

View file

@ -17,6 +17,7 @@ import React from "react";
import PropTypes from 'prop-types';
import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
import Modal from '../../../Modal';
import sdk from "../../../index";
module.exports = React.createClass({
@ -31,6 +32,7 @@ module.exports = React.createClass({
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
viewAvatarOnClick: PropTypes.bool,
},
getDefaultProps: function() {
@ -48,12 +50,34 @@ module.exports = React.createClass({
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents);
}
},
componentWillReceiveProps: function(newProps) {
this.setState({
urls: this.getImageUrls(newProps),
});
},
onRoomStateEvents: function(ev) {
if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar'
) return;
this.setState({
urls: this.getImageUrls(this.props),
});
},
getImageUrls: function(props) {
return [
ContentRepo.getHttpUriForMxc(
@ -87,10 +111,15 @@ module.exports = React.createClass({
const mlist = props.room.currentState.members;
const userIds = [];
const leftUserIds = [];
// for .. in optimisation to return early if there are >2 keys
for (const uid in mlist) {
if (mlist.hasOwnProperty(uid)) {
userIds.push(uid);
if (["join", "invite"].includes(mlist[uid].membership)) {
userIds.push(uid);
} else {
leftUserIds.push(uid);
}
}
if (userIds.length > 2) {
return null;
@ -112,6 +141,14 @@ module.exports = React.createClass({
false,
);
} else if (userIds.length == 1) {
// The other 1-1 user left, leaving just the current user, so show the left user's avatar
if (leftUserIds.length === 1) {
return mlist[leftUserIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false,
);
}
return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
@ -124,17 +161,32 @@ module.exports = React.createClass({
}
},
onRoomAvatarClick: function() {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
null, null, null, false);
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: avatarUrl,
name: this.props.room.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
},
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const {room, oobData, ...otherProps} = this.props;
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name;
return (
<BaseAvatar {...otherProps} name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls} />
urls={this.state.urls}
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null} />
);
},
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
/*
* This component can be used to display generic HTML content in a contextual
* menu.
*/
export default class GenericElementContextMenu extends React.Component {
static PropTypes = {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize: PropTypes.func,
};
constructor(props) {
super(props);
this.resize = this.resize.bind(this);
}
componentDidMount() {
this.resize = this.resize.bind(this);
window.addEventListener("resize", this.resize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.resize);
}
resize() {
if (this.props.onResize) {
this.props.onResize();
}
}
render() {
return <div>{ this.props.element }</div>;
}
}

View file

@ -0,0 +1,30 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
export default class GenericTextContextMenu extends React.Component {
static PropTypes = {
message: PropTypes.string.isRequired,
};
render() {
return <div>{ this.props.message }</div>;
}
}

View file

@ -0,0 +1,344 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import {makeEventPermalink} from '../../../matrix-to';
import { isUrlPermitted } from '../../../HtmlUtils';
module.exports = React.createClass({
displayName: 'MessageContextMenu',
propTypes: {
/* the MatrixEvent associated with the context menu */
mxEvent: PropTypes.object.isRequired,
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
eventTileOps: PropTypes.object,
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyThread: PropTypes.func,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func,
},
getInitialState: function() {
return {
canRedact: false,
canPin: false,
};
},
componentWillMount: function() {
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
this._checkPermissions();
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
}
},
_checkPermissions: function() {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.isFeatureEnabled("feature_pinning")) canPin = false;
this.setState({canRedact, canPin});
},
_isPinned: function() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
if (!pinnedEvent) return false;
return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
},
onResendClick: function() {
Resend.resend(this.props.mxEvent);
this.closeMenu();
},
onViewSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
content: this.props.mxEvent.event,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
onViewClearSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
// FIXME: _clearEvent is private
content: this.props.mxEvent._clearEvent,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
onRedactClick: function() {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: (proceed) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
cli.redactEvent(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()).catch(function(e) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this.
const code = e.errcode || e.statusCode;
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this message. (%(code)s)', {code}),
});
}).done();
},
}, 'mx_Dialog_confirmredact');
this.closeMenu();
},
onCancelSendClick: function() {
Resend.removeFromQueue(this.props.mxEvent);
this.closeMenu();
},
onForwardClick: function() {
dis.dispatch({
action: 'forward_event',
event: this.props.mxEvent,
});
this.closeMenu();
},
onPinClick: function() {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
.catch((e) => {
// Intercept the Event Not Found error and fall through the promise chain with no event.
if (e.errcode === "M_NOT_FOUND") return null;
throw e;
})
.then((event) => {
const eventIds = (event ? event.pinned : []) || [];
if (!eventIds.includes(this.props.mxEvent.getId())) {
// Not pinned - add
eventIds.push(this.props.mxEvent.getId());
} else {
// Pinned - remove
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
}
const cli = MatrixClientPeg.get();
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
});
this.closeMenu();
},
closeMenu: function() {
if (this.props.onFinished) this.props.onFinished();
},
onUnhidePreviewClick: function() {
if (this.props.eventTileOps) {
this.props.eventTileOps.unhideWidget();
}
this.closeMenu();
},
onQuoteClick: function() {
dis.dispatch({
action: 'quote',
text: this.props.eventTileOps.getInnerText(),
});
this.closeMenu();
},
onReplyClick: function() {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
});
this.closeMenu();
},
onCollapseReplyThreadClick: function() {
this.props.collapseReplyThread();
this.closeMenu();
},
render: function() {
const eventStatus = this.props.mxEvent.status;
let resendButton;
let redactButton;
let cancelButton;
let forwardButton;
let pinButton;
let viewClearSourceButton;
let unhidePreviewButton;
let externalURLButton;
let quoteButton;
let replyButton;
let collapseReplyThread;
if (eventStatus === 'not_sent') {
resendButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</div>
);
}
if (!eventStatus && this.state.canRedact) {
redactButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') }
</div>
);
}
if (eventStatus === "queued" || eventStatus === "not_sent") {
cancelButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</div>
);
}
if (!eventStatus && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</div>
);
if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) {
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
}
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
}
}
}
const viewSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
{ _t('View Source') }
</div>
);
if (this.props.mxEvent.getType() !== this.props.mxEvent.getWireType()) {
viewClearSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
{ _t('View Decrypted Source') }
</div>
);
}
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
{ _t('Unhide Preview') }
</div>
);
}
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<div className="mx_MessageContextMenu_field">
<a href={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())}
target="_blank" rel="noopener" onClick={this.closeMenu}>{ _t('Permalink') }</a>
</div>
);
if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) {
quoteButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
{ _t('Quote') }
</div>
);
}
// Bridges can provide a 'external_url' to link back to the source.
if (
typeof(this.props.mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(this.props.mxEvent.event.content.external_url)
) {
externalURLButton = (
<div className="mx_MessageContextMenu_field">
<a href={this.props.mxEvent.event.content.external_url}
rel="noopener" target="_blank" onClick={this.closeMenu}>{ _t('Source URL') }</a>
</div>
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<div className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
{ _t('Collapse Reply Thread') }
</div>
);
}
return (
<div>
{ resendButton }
{ redactButton }
{ cancelButton }
{ forwardButton }
{ pinButton }
{ viewSourceButton }
{ viewClearSourceButton }
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
</div>
);
},
});

View file

@ -0,0 +1,368 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import Promise from 'bluebird';
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import DMRoomMap from '../../../utils/DMRoomMap';
import * as Rooms from '../../../Rooms';
import * as RoomNotifs from '../../../RoomNotifs';
import Modal from '../../../Modal';
import RoomListActions from '../../../actions/RoomListActions';
module.exports = React.createClass({
displayName: 'RoomTileContextMenu',
propTypes: {
room: PropTypes.object.isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func,
},
getInitialState() {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
return {
roomNotifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
isFavourite: this.props.room.tags.hasOwnProperty("m.favourite"),
isLowPriority: this.props.room.tags.hasOwnProperty("m.lowpriority"),
isDirectMessage: Boolean(dmRoomMap.getUserIdForRoomId(this.props.room.roomId)),
};
},
componentWillMount: function() {
this._unmounted = false;
},
componentWillUnmount: function() {
this._unmounted = true;
},
_toggleTag: function(tagNameOn, tagNameOff) {
if (!MatrixClientPeg.get().isGuest()) {
Promise.delay(500).then(() => {
dis.dispatch(RoomListActions.tagRoom(
MatrixClientPeg.get(),
this.props.room,
tagNameOff, tagNameOn,
undefined, 0,
), true);
this.props.onFinished();
});
}
},
_onClickFavourite: function() {
// Tag room as 'Favourite'
if (!this.state.isFavourite && this.state.isLowPriority) {
this.setState({
isFavourite: true,
isLowPriority: false,
});
this._toggleTag("m.favourite", "m.lowpriority");
} else if (this.state.isFavourite) {
this.setState({isFavourite: false});
this._toggleTag(null, "m.favourite");
} else if (!this.state.isFavourite) {
this.setState({isFavourite: true});
this._toggleTag("m.favourite");
}
},
_onClickLowPriority: function() {
// Tag room as 'Low Priority'
if (!this.state.isLowPriority && this.state.isFavourite) {
this.setState({
isFavourite: false,
isLowPriority: true,
});
this._toggleTag("m.lowpriority", "m.favourite");
} else if (this.state.isLowPriority) {
this.setState({isLowPriority: false});
this._toggleTag(null, "m.lowpriority");
} else if (!this.state.isLowPriority) {
this.setState({isLowPriority: true});
this._toggleTag("m.lowpriority");
}
},
_onClickDM: function() {
if (MatrixClientPeg.get().isGuest()) return;
const newIsDirectMessage = !this.state.isDirectMessage;
this.setState({
isDirectMessage: newIsDirectMessage,
});
Rooms.guessAndSetDMRoom(
this.props.room, newIsDirectMessage,
).delay(500).finally(() => {
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
}, (err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to set Direct Message status of room', '', ErrorDialog, {
title: _t('Failed to set Direct Message status of room'),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
},
_onClickLeave: function() {
// Leave room
dis.dispatch({
action: 'leave_room',
room_id: this.props.room.roomId,
});
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
},
_onClickReject: function() {
dis.dispatch({
action: 'reject_invite',
room_id: this.props.room.roomId,
});
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
},
_onClickForget: function() {
// FIXME: duplicated with RoomSettings (and dead code in RoomView)
MatrixClientPeg.get().forget(this.props.room.roomId).done(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
const errCode = err.errcode || _td("unknown error code");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
title: _t('Failed to forget room %(errCode)s', {errCode: errCode}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
},
_saveNotifState: function(newState) {
if (MatrixClientPeg.get().isGuest()) return;
const oldState = this.state.roomNotifState;
const roomId = this.props.room.roomId;
this.setState({
roomNotifState: newState,
});
RoomNotifs.setRoomNotifsState(roomId, newState).done(() => {
// delay slightly so that the user can see their state change
// before closing the menu
return Promise.delay(500).then(() => {
if (this._unmounted) return;
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
});
}, (error) => {
// TODO: some form of error notification to the user
// to inform them that their state change failed.
// For now we at least set the state back
if (this._unmounted) return;
this.setState({
roomNotifState: oldState,
});
});
},
_onClickAlertMe: function() {
this._saveNotifState(RoomNotifs.ALL_MESSAGES_LOUD);
},
_onClickAllNotifs: function() {
this._saveNotifState(RoomNotifs.ALL_MESSAGES);
},
_onClickMentions: function() {
this._saveNotifState(RoomNotifs.MENTIONS_ONLY);
},
_onClickMute: function() {
this._saveNotifState(RoomNotifs.MUTE);
},
_renderNotifMenu: function() {
const alertMeClasses = classNames({
'mx_RoomTileContextMenu_notif_field': true,
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES_LOUD,
});
const allNotifsClasses = classNames({
'mx_RoomTileContextMenu_notif_field': true,
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES,
});
const mentionsClasses = classNames({
'mx_RoomTileContextMenu_notif_field': true,
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MENTIONS_ONLY,
});
const muteNotifsClasses = classNames({
'mx_RoomTileContextMenu_notif_field': true,
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MUTE,
});
return (
<div>
<div className="mx_RoomTileContextMenu_notif_picker" >
<img src="img/notif-slider.svg" width="20" height="107" />
</div>
<div className={alertMeClasses} onClick={this._onClickAlertMe} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off-copy.svg" width="16" height="12" />
{ _t('All messages (noisy)') }
</div>
<div className={allNotifsClasses} onClick={this._onClickAllNotifs} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off.svg" width="16" height="12" />
{ _t('All messages') }
</div>
<div className={mentionsClasses} onClick={this._onClickMentions} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-mentions.svg" width="16" height="12" />
{ _t('Mentions only') }
</div>
<div className={muteNotifsClasses} onClick={this._onClickMute} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute.svg" width="16" height="12" />
{ _t('Mute') }
</div>
</div>
);
},
_renderLeaveMenu: function(membership) {
if (!membership) {
return null;
}
let leaveClickHandler = null;
let leaveText = null;
switch (membership) {
case "join":
leaveClickHandler = this._onClickLeave;
leaveText = _t('Leave');
break;
case "leave":
case "ban":
leaveClickHandler = this._onClickForget;
leaveText = _t('Forget');
break;
case "invite":
leaveClickHandler = this._onClickReject;
leaveText = _t('Reject');
break;
}
return (
<div>
<div className="mx_RoomTileContextMenu_leave" onClick={leaveClickHandler} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_delete.svg" width="15" height="15" />
{ leaveText }
</div>
</div>
);
},
_renderRoomTagMenu: function() {
const favouriteClasses = classNames({
'mx_RoomTileContextMenu_tag_field': true,
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isFavourite,
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
});
const lowPriorityClasses = classNames({
'mx_RoomTileContextMenu_tag_field': true,
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isLowPriority,
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
});
const dmClasses = classNames({
'mx_RoomTileContextMenu_tag_field': true,
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isDirectMessage,
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
});
return (
<div>
<div className={favouriteClasses} onClick={this._onClickFavourite} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_fave.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_fave_on.svg" width="15" height="15" />
{ _t('Favourite') }
</div>
<div className={lowPriorityClasses} onClick={this._onClickLowPriority} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_low.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_low_on.svg" width="15" height="15" />
{ _t('Low Priority') }
</div>
<div className={dmClasses} onClick={this._onClickDM} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_person.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_person_on.svg" width="15" height="15" />
{ _t('Direct Chat') }
</div>
</div>
);
},
render: function() {
const myMember = this.props.room.getMember(
MatrixClientPeg.get().credentials.userId,
);
// Can't set notif level or tags on non-join rooms
if (myMember.membership !== 'join') {
return this._renderLeaveMenu(myMember.membership);
}
return (
<div>
{ this._renderNotifMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderLeaveMenu(myMember.membership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
</div>
);
},
});

View file

@ -0,0 +1,75 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import TagOrderActions from '../../../actions/TagOrderActions';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
export default class TagTileContextMenu extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
this._onRemoveClick = this._onRemoveClick.bind(this);
}
_onViewCommunityClick() {
dis.dispatch({
action: 'view_group',
group_id: this.props.tag,
});
this.props.onFinished();
}
_onRemoveClick() {
dis.dispatch(TagOrderActions.removeTag(
// XXX: Context menus don't have a MatrixClient context
MatrixClientPeg.get(),
this.props.tag,
));
this.props.onFinished();
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return <div>
<div className="mx_TagTileContextMenu_item" onClick={this._onViewCommunityClick} >
<TintableSvg
className="mx_TagTileContextMenu_item_icon"
src="img/icons-groups.svg"
width="15"
height="15"
/>
{ _t('View Community') }
</div>
<hr className="mx_TagTileContextMenu_separator" />
<div className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick} >
<img className="mx_TagTileContextMenu_item_icon" src="img/icon_context_delete.svg" width="15" height="15" />
{ _t('Remove') }
</div>
</div>;
}
}

View file

@ -22,7 +22,7 @@ import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GroupStore from '../../../stores/GroupStore';
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -243,9 +243,8 @@ module.exports = React.createClass({
_doNaiveGroupRoomSearch: function(query) {
const lowerCaseQuery = query.toLowerCase();
const groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
const results = [];
groupStore.getGroupRooms().forEach((r) => {
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery);
const topicMatch = (r.topic || '').toLowerCase().includes(lowerCaseQuery);
const aliasMatch = (r.canonical_alias || '').toLowerCase().includes(lowerCaseQuery);

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,11 +16,15 @@ limitations under the License.
*/
import React from 'react';
import FocusTrap from 'focus-trap-react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import { KeyCode } from '../../../Keyboard';
import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/**
* Basic container for modal dialogs.
@ -32,10 +37,20 @@ export default React.createClass({
propTypes: {
// onFinished callback to call when Escape is pressed
// Take a boolean which is true if the dialog was dismissed
// with a positive / confirm action or false if it was
// cancelled (BaseDialog itself only calls this with false).
onFinished: PropTypes.func.isRequired,
// callback to call when Enter is pressed
onEnterPressed: PropTypes.func,
// Whether the dialog should have a 'close' button that will
// cause the dialog to be cancelled. This should only be set
// to false if there is nothing the app can sensibly do if the
// dialog is cancelled, eg. "We can't restore your session and
// the app cannot work". Default: true.
hasCancel: PropTypes.bool,
// called when a key is pressed
onKeyDown: PropTypes.func,
// CSS class to apply to dialog div
className: PropTypes.string,
@ -46,41 +61,73 @@ export default React.createClass({
// children should be the content of the dialog
children: PropTypes.node,
// Id of content element
// If provided, this is used to add a aria-describedby attribute
contentId: React.PropTypes.string,
},
getDefaultProps: function() {
return {
hasCancel: true,
};
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
};
},
componentWillMount() {
this._matrixClient = MatrixClientPeg.get();
},
_onKeyDown: function(e) {
if (e.keyCode === KeyCode.ESCAPE) {
if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
if (this.props.hasCancel && e.keyCode === KeyCode.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.props.onFinished();
} else if (e.keyCode === KeyCode.ENTER) {
if (this.props.onEnterPressed) {
e.stopPropagation();
e.preventDefault();
this.props.onEnterPressed(e);
}
this.props.onFinished(false);
}
},
_onCancelClick: function(e) {
this.props.onFinished();
this.props.onFinished(false);
},
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<div onKeyDown={this._onKeyDown} className={this.props.className}>
<AccessibleButton onClick={this._onCancelClick}
<FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
// If we were about to completelly follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.
// So instead we will use the whole content as the description.
// Description comes first and if the content contains more text,
// AT users can skip its presentation.
aria-describedby={this.props.contentId}
>
{ this.props.hasCancel ? <AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton"
>
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton>
<div className={'mx_Dialog_title ' + this.props.titleClass}>
</AccessibleButton> : null }
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
{ this.props.title }
</div>
{ this.props.children }
</div>
</FocusTrap>
);
},
});

View file

@ -0,0 +1,203 @@
/*
Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
export default class BugReportDialog extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
sendLogs: true,
busy: false,
err: null,
issueUrl: "",
text: "",
progress: null,
};
this._unmounted = false;
this._onSubmit = this._onSubmit.bind(this);
this._onCancel = this._onCancel.bind(this);
this._onTextChange = this._onTextChange.bind(this);
this._onIssueUrlChange = this._onIssueUrlChange.bind(this);
this._onSendLogsChange = this._onSendLogsChange.bind(this);
this._sendProgressCallback = this._sendProgressCallback.bind(this);
}
componentWillUnmount() {
this._unmounted = true;
}
_onCancel(ev) {
this.props.onFinished(false);
}
_onSubmit(ev) {
const userText =
(this.state.text.length > 0 ? this.state.text + '\n\n': '') + 'Issue: ' +
(this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given');
this.setState({ busy: true, progress: null, err: null });
this._sendProgressCallback(_t("Preparing to send logs"));
require(['../../../rageshake/submit-rageshake'], (s) => {
s(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
}).then(() => {
if (!this._unmounted) {
this.props.onFinished(false);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
title: _t('Logs sent'),
description: _t('Thank you!'),
hasCancelButton: false,
});
}
}, (err) => {
if (!this._unmounted) {
this.setState({
busy: false,
progress: null,
err: _t("Failed to send logs: ") + `${err.message}`,
});
}
});
});
}
_onTextChange(ev) {
this.setState({ text: ev.target.value });
}
_onIssueUrlChange(ev) {
this.setState({ issueUrl: ev.target.value });
}
_onSendLogsChange(ev) {
this.setState({ sendLogs: ev.target.checked });
}
_sendProgressCallback(progress) {
if (this._unmounted) {
return;
}
this.setState({progress: progress});
}
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
{this.state.progress} ...
</div>
);
}
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>
{ _t(
"Debug logs contain application usage data including your " +
"username, the IDs or aliases of the rooms or groups you " +
"have visited and the usernames of other users. They do " +
"not contain messages.",
) }
</p>
<p>
{ _t(
"Riot bugs are tracked on GitHub: <a>create a GitHub issue</a>.",
{},
{
a: (sub) => <a
target="_blank"
href="https://github.com/vector-im/riot-web/issues/new"
>
{ sub }
</a>,
},
) }
</p>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_issueUrl"
className="mx_BugReportDialog_field_label"
>
{ _t("GitHub issue link:") }
</label>
<input
id="mx_BugReportDialog_issueUrl"
type="text"
className="mx_BugReportDialog_field_input"
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/1337"
/>
</div>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_notes_label"
className="mx_BugReportDialog_field_label"
>
{ _t("Notes:") }
</label>
<textarea
className="mx_BugReportDialog_field_input"
rows={5}
onChange={this._onTextChange}
value={this.state.text}
/>
</div>
{progress}
{error}
</div>
<DialogButtons primaryButton={_t("Send logs")}
onPrimaryButtonClick={this._onSubmit}
focus={true}
onCancel={this._onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}
BugReportDialog.propTypes = {
onFinished: React.PropTypes.func.isRequired,
};

View file

@ -0,0 +1,94 @@
/*
Copyright 2016 Aviral Dasgupta
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 sdk from '../../../index';
import request from 'browser-request';
import { _t } from '../../../languageHandler';
const REPOS = ['vector-im/riot-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
export default class ChangelogDialog extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
const version = this.props.newVersion.split('-');
const version2 = this.props.version.split('-');
if(version == null || version2 == null) return;
// parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version]
for(let i=0; i<REPOS.length; i++) {
const oldVersion = version2[2*i];
const newVersion = version[2*i];
request(`https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`, (a, b, body) => {
if(body == null) return;
this.setState({[REPOS[i]]: JSON.parse(body).commits});
});
}
}
_elementsForCommit(commit) {
return (
<li key={commit.sha} className="mx_ChangelogDialog_li">
<a href={commit.html_url} target="_blank" rel="noopener">
{commit.commit.message}
</a>
</li>
);
}
render() {
const Spinner = sdk.getComponent('views.elements.Spinner');
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
const logs = REPOS.map(repo => {
if (this.state[repo] == null) return <Spinner key={repo} />;
return (
<div key={repo}>
<h2>{repo}</h2>
<ul>
{this.state[repo].map(this._elementsForCommit)}
</ul>
</div>
)
});
const content = (
<div className="mx_ChangelogDialog_content">
{this.props.version == null || this.props.newVersion == null ? <h2>{_t("Unavailable")}</h2> : logs}
</div>
);
return (
<QuestionDialog
title={_t("Changelog")}
description={content}
button={_t("Update")}
onFinished={this.props.onFinished}
/>
)
}
}
ChangelogDialog.propTypes = {
version: React.PropTypes.string.isRequired,
newVersion: React.PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired,
};

View file

@ -59,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
);
tiles.push(
<RoomTile key={room.roomId} room={room}
transparent={true}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
@ -128,7 +129,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div>
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
</AccessibleButton>;
content = <div className="mx_Dialog_content">
content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ _t('You already have existing direct chats with this user:') }
<div className="mx_ChatCreateOrReuseDialog_tiles">
{ this.state.tiles }
@ -146,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
if (this.state.busyProfile) {
profile = <Spinner />;
} else if (this.state.profileError) {
profile = <div className="error">
profile = <div className="error" role="alert">
Unable to load profile information for { this.props.userId }
</div>;
} else {
@ -162,14 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div>;
}
content = <div>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>
{ _t('Click on the button below to start chatting!') }
</p>
{ profile }
</div>
<DialogButtons primaryButton={_t('Start Chatting')}
onPrimaryButtonClick={this.props.onNewDMClick} />
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
</div>;
}
@ -178,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={this.props.onFinished.bind(false)}
title={title}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -114,10 +114,10 @@ export default React.createClass({
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar">
{ avatar }
</div>

View file

@ -55,11 +55,15 @@ export default React.createClass({
_checkGroupId: function(e) {
let error = null;
if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
if (!this.state.groupId) {
error = _t("Community IDs cannot not be empty.");
} else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'");
}
this.setState({
groupIdError: error,
// Reset createError to get rid of now stale error message
createError: null,
});
return error;
},
@ -108,7 +112,7 @@ export default React.createClass({
// XXX: We should catch errcodes and give sensible i18ned messages for them,
// rather than displaying what the server gives us, but synapse doesn't give
// any yet.
createErrorNode = <div className="error">
createErrorNode = <div className="error" role="alert">
<div>{ _t('Something went wrong whilst creating your community') }</div>
<div>{ this.state.createError.message }</div>
</div>;
@ -116,7 +120,6 @@ export default React.createClass({
return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
onEnterPressed={this._onFormSubmit}
title={_t('Create Community')}
>
<form onSubmit={this._onFormSubmit}>

View file

@ -45,30 +45,31 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={_t('Create Room')}
>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
</details>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
</div>
</details>
</div>
</form>
<DialogButtons primaryButton={_t('Create Room')}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} />

View file

@ -33,10 +33,21 @@ export default class DeactivateAccountDialog extends React.Component {
this._onOk = this._onOk.bind(this);
this._onCancel = this._onCancel.bind(this);
this._onPasswordFieldChange = this._onPasswordFieldChange.bind(this);
this._onEraseFieldChange = this._onEraseFieldChange.bind(this);
const deactivationPreferences =
MatrixClientPeg.get().getAccountData('im.riot.account_deactivation_preferences');
const shouldErase = (
deactivationPreferences &&
deactivationPreferences.getContent() &&
deactivationPreferences.getContent().shouldErase
) || false;
this.state = {
confirmButtonEnabled: false,
busy: false,
shouldErase,
errStr: null,
};
}
@ -47,19 +58,55 @@ export default class DeactivateAccountDialog extends React.Component {
});
}
_onOk() {
// This assumes that the HS requires password UI auth
// for this endpoint. In reality it could be any UI auth.
_onEraseFieldChange(ev) {
this.setState({
shouldErase: ev.target.checked,
});
}
async _onOk() {
this.setState({busy: true});
MatrixClientPeg.get().deactivateAccount({
type: 'm.login.password',
user: MatrixClientPeg.get().credentials.userId,
password: this._passwordField.value,
}).done(() => {
Analytics.trackEvent('Account', 'Deactivate Account');
Lifecycle.onLoggedOut();
this.props.onFinished(false);
}, (err) => {
// Before we deactivate the account insert an event into
// the user's account data indicating that they wish to be
// erased from the homeserver.
//
// We do this because the API for erasing after deactivation
// might not be supported by the connected homeserver. Leaving
// an indication in account data is only best-effort, and
// in the worse case, the HS maintainer would have to run a
// script to erase deactivated accounts that have shouldErase
// set to true in im.riot.account_deactivation_preferences.
//
// Note: The preferences are scoped to Riot, hence the
// "im.riot..." event type.
//
// Note: This may have already been set on previous attempts
// where, for example, the user entered the wrong password.
// This is fine because the UI always indicates the preference
// prior to us calling `deactivateAccount`.
try {
await MatrixClientPeg.get().setAccountData('im.riot.account_deactivation_preferences', {
shouldErase: this.state.shouldErase,
});
} catch (err) {
this.setState({
busy: false,
errStr: _t('Failed to indicate account erasure'),
});
return;
}
try {
// This assumes that the HS requires password UI auth
// for this endpoint. In reality it could be any UI auth.
const auth = {
type: 'm.login.password',
user: MatrixClientPeg.get().credentials.userId,
password: this._passwordField.value,
};
await MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase);
} catch (err) {
let errStr = _t('Unknown error');
// https://matrix.org/jira/browse/SYN-744
if (err.httpStatus == 401 || err.httpStatus == 403) {
@ -70,7 +117,12 @@ export default class DeactivateAccountDialog extends React.Component {
busy: false,
errStr: errStr,
});
});
return;
}
Analytics.trackEvent('Account', 'Deactivate Account');
Lifecycle.onLoggedOut();
this.props.onFinished(false);
}
_onCancel() {
@ -105,21 +157,64 @@ export default class DeactivateAccountDialog extends React.Component {
onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
titleClass="danger"
title={_t("Deactivate Account")}>
title={_t("Deactivate Account")}
>
<div className="mx_Dialog_content">
<p>{ _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }</p>
<p>{ _t(
"This will make your account permanently unusable. " +
"You will not be able to log in, and no one will be able to re-register the same " +
"user ID. " +
"This will cause your account to leave all rooms it is participating in, and it " +
"will remove your account details from your identity server. " +
"<b>This action is irreversible.</b>",
{},
{ b: (sub) => <b> { sub } </b> },
) }</p>
<p>{ _t("This action is irreversible.") }</p>
<p>{ _t(
"Deactivating your account <b>does not by default cause us to forget messages you " +
"have sent.</b> " +
"If you would like us to forget your messages, please tick the box below.",
{},
{ b: (sub) => <b> { sub } </b> },
) }</p>
<p>{ _t("To continue, please enter your password.") }</p>
<p>{ _t(
"Message visibility in Matrix is similar to email. " +
"Our forgetting your messages means that messages you have sent will not be shared " +
"with any new or unregistered users, but registered users who already have access " +
"to these messages will still have access to their copy.",
) }</p>
<div className="mx_DeactivateAccountDialog_input_section">
<p>
<label htmlFor="mx_DeactivateAccountDialog_erase_account_input">
<input
id="mx_DeactivateAccountDialog_erase_account_input"
type="checkbox"
checked={this.state.shouldErase}
onChange={this._onEraseFieldChange}
/>
{ _t(
"Please forget all messages I have sent when my account is deactivated " +
"(<b>Warning:</b> this will cause future users to see an incomplete view " +
"of conversations)",
{},
{ b: (sub) => <b>{ sub }</b> },
) }
</label>
</p>
<p>{ _t("To continue, please enter your password:") }</p>
<input
type="password"
placeholder={_t("password")}
onChange={this._onPasswordFieldChange}
ref={(e) => {this._passwordField = e;}}
className={passwordBoxClass}
/>
</div>
<p>{ _t("Password") }:</p>
<input
type="password"
onChange={this._onPasswordFieldChange}
ref={(e) => {this._passwordField = e;}}
className={passwordBoxClass}
/>
{ error }
</div>
<div className="mx_Dialog_buttons">

View file

@ -0,0 +1,623 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
class DevtoolsComponent extends React.Component {
static contextTypes = {
roomId: PropTypes.string.isRequired,
};
}
class GenericEditor extends DevtoolsComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};
constructor(props, context) {
super(props, context);
this._onChange = this._onChange.bind(this);
this.onBack = this.onBack.bind(this);
}
onBack() {
if (this.state.message) {
this.setState({ message: null });
} else {
this.props.onBack();
}
}
_onChange(e) {
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
_buttons() {
return <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
</div>;
}
textInput(id, label) {
return <div className="mx_DevTools_inputRow">
<div className="mx_DevTools_inputLabelCell">
<label htmlFor={id}>{ label }</label>
</div>
<div className="mx_DevTools_inputCell">
<input id={id} className="mx_TextInputDialog_input" onChange={this._onChange} value={this.state[id]} size="32" />
</div>
</div>;
}
}
class SendCustomEvent extends GenericEditor {
static getLabel() { return _t('Send Custom Event'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
forceStateEvent: PropTypes.bool,
inputs: PropTypes.object,
};
constructor(props, context) {
super(props, context);
this._send = this._send.bind(this);
const {eventType, stateKey, evContent} = Object.assign({
eventType: '',
stateKey: '',
evContent: '{\n\n}',
}, this.props.inputs);
this.state = {
isStateEvent: Boolean(this.props.forceStateEvent),
eventType,
stateKey,
evContent,
};
}
send(content) {
const cli = MatrixClientPeg.get();
if (this.state.isStateEvent) {
return cli.sendStateEvent(this.context.roomId, this.state.eventType, content, this.state.stateKey);
} else {
return cli.sendEvent(this.context.roomId, this.state.eventType, content);
}
}
async _send() {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
}
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
}
this.setState({ message });
}
render() {
if (this.state.message) {
return <div>
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
</div>;
}
return <div>
<div className="mx_Dialog_content">
{ this.textInput('eventType', _t('Event Type')) }
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
<br />
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" />
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && !this.props.forceStateEvent && <div style={{float: "right"}}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" />
</div> }
</div>
</div>;
}
}
class SendAccountData extends GenericEditor {
static getLabel() { return _t('Send Account Data'); }
static propTypes = {
isRoomAccountData: PropTypes.bool,
forceMode: PropTypes.bool,
inputs: PropTypes.object,
};
constructor(props, context) {
super(props, context);
this._send = this._send.bind(this);
const {eventType, evContent} = Object.assign({
eventType: '',
evContent: '{\n\n}',
}, this.props.inputs);
this.state = {
isRoomAccountData: Boolean(this.props.isRoomAccountData),
eventType,
evContent,
};
}
send(content) {
const cli = MatrixClientPeg.get();
if (this.state.isRoomAccountData) {
return cli.setRoomAccountData(this.context.roomId, this.state.eventType, content);
}
return cli.setAccountData(this.state.eventType, content);
}
async _send() {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
}
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
}
this.setState({ message });
}
render() {
if (this.state.message) {
return <div>
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
</div>;
}
return <div>
<div className="mx_Dialog_content">
{ this.textInput('eventType', _t('Event Type')) }
<br />
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" />
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
</div> }
</div>
</div>;
}
}
class FilteredList extends React.Component {
static propTypes = {
children: PropTypes.any,
query: PropTypes.string,
onChange: PropTypes.func,
};
constructor(props, context) {
super(props, context);
this.onQuery = this.onQuery.bind(this);
}
onQuery(ev) {
if (this.props.onChange) this.props.onChange(ev.target.value);
}
filterChildren() {
if (this.props.query) {
const lowerQuery = this.props.query.toLowerCase();
return this.props.children.filter((child) => child.key.toLowerCase().includes(lowerQuery));
}
return this.props.children;
}
render() {
return <div>
<input size="64"
onChange={this.onQuery}
value={this.props.query}
placeholder={_t('Filter results')}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" />
{ this.filterChildren() }
</div>;
}
}
class RoomStateExplorer extends DevtoolsComponent {
static getLabel() { return _t('Explore Room State'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
const room = MatrixClientPeg.get().getRoom(this.context.roomId);
this.roomStateEvents = room.currentState.events;
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.onQueryStateKey = this.onQueryStateKey.bind(this);
this.state = {
eventType: null,
event: null,
editing: false,
queryEventType: '',
queryStateKey: '',
};
}
browseEventType(eventType) {
return () => {
this.setState({ eventType });
};
}
onViewSourceClick(event) {
return () => {
this.setState({ event });
};
}
onBack() {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
this.setState({ event: null });
} else if (this.state.eventType) {
this.setState({ eventType: null });
} else {
this.props.onBack();
}
}
editEv() {
this.setState({ editing: true });
}
onQueryEventType(filterEventType) {
this.setState({ queryEventType: filterEventType });
}
onQueryStateKey(filterStateKey) {
this.setState({ queryStateKey: filterStateKey });
}
render() {
if (this.state.event) {
if (this.state.editing) {
return <SendCustomEvent forceStateEvent={true} onBack={this.onBack} inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
}} />;
}
return <div className="mx_ViewSource">
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.state.event.event, null, 2) }
</SyntaxHighlight>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
<button onClick={this.editEv}>{ _t('Edit') }</button>
</div>
</div>;
}
let list = null;
const classes = 'mx_DevTools_RoomStateExplorer_button';
if (this.state.eventType === null) {
list = <FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
{
Object.keys(this.roomStateEvents).map((evType) => {
const stateGroup = this.roomStateEvents[evType];
const stateKeys = Object.keys(stateGroup);
let onClickFn;
if (stateKeys.length > 1) {
onClickFn = this.browseEventType(evType);
} else if (stateKeys.length === 1) {
onClickFn = this.onViewSourceClick(stateGroup[stateKeys[0]]);
}
return <button className={classes} key={evType} onClick={onClickFn}>
{ evType }
</button>;
})
}
</FilteredList>;
} else {
const stateGroup = this.roomStateEvents[this.state.eventType];
list = <FilteredList query={this.state.queryStateKey} onChange={this.onQueryStateKey}>
{
Object.keys(stateGroup).map((stateKey) => {
const ev = stateGroup[stateKey];
return <button className={classes} key={stateKey} onClick={this.onViewSourceClick(ev)}>
{ stateKey }
</button>;
})
}
</FilteredList>;
}
return <div>
<div className="mx_Dialog_content">
{ list }
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
</div>
</div>;
}
}
class AccountDataExplorer extends DevtoolsComponent {
static getLabel() { return _t('Explore Account Data'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this._onChange = this._onChange.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.state = {
isRoomAccountData: false,
event: null,
editing: false,
queryEventType: '',
};
}
getData() {
const cli = MatrixClientPeg.get();
if (this.state.isRoomAccountData) {
return cli.getRoom(this.context.roomId).accountData;
}
return cli.store.accountData;
}
onViewSourceClick(event) {
return () => {
this.setState({ event });
};
}
onBack() {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
this.setState({ event: null });
} else {
this.props.onBack();
}
}
_onChange(e) {
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
editEv() {
this.setState({ editing: true });
}
onQueryEventType(queryEventType) {
this.setState({ queryEventType });
}
render() {
if (this.state.event) {
if (this.state.editing) {
return <SendAccountData isRoomAccountData={this.state.isRoomAccountData} onBack={this.onBack} inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
}} forceMode={true} />;
}
return <div className="mx_ViewSource">
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.state.event.event, null, 2) }
</SyntaxHighlight>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
<button onClick={this.editEv}>{ _t('Edit') }</button>
</div>
</div>;
}
const rows = [];
const classes = 'mx_DevTools_RoomStateExplorer_button';
const data = this.getData();
Object.keys(data).forEach((evType) => {
const ev = data[evType];
rows.push(<button className={classes} key={evType} onClick={this.onViewSourceClick(ev)}>
{ evType }
</button>);
});
return <div>
<div className="mx_Dialog_content">
<FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
{ rows }
</FilteredList>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
</div> }
</div>
</div>;
}
}
const Entries = [
SendCustomEvent,
RoomStateExplorer,
SendAccountData,
AccountDataExplorer,
];
export default class DevtoolsDialog extends React.Component {
static childContextTypes = {
roomId: PropTypes.string.isRequired,
// client: PropTypes.instanceOf(MatixClient),
};
static propTypes = {
roomId: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
this.onBack = this.onBack.bind(this);
this.onCancel = this.onCancel.bind(this);
this.state = {
mode: null,
};
}
componentWillUnmount() {
this._unmounted = true;
}
getChildContext() {
return { roomId: this.props.roomId };
}
_setMode(mode) {
return () => {
this.setState({ mode });
};
}
onBack() {
if (this.prevMode) {
this.setState({ mode: this.prevMode });
this.prevMode = null;
} else {
this.setState({ mode: null });
}
}
onCancel() {
this.props.onFinished(false);
}
render() {
let body;
if (this.state.mode) {
body = <div>
<div className="mx_DevTools_label_left">{ this.state.mode.getLabel() }</div>
<div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div>
<div className="mx_DevTools_label_bottom" />
<this.state.mode onBack={this.onBack} />
</div>;
} else {
const classes = "mx_DevTools_RoomStateExplorer_button";
body = <div>
<div>
<div className="mx_DevTools_label_left">{ _t('Toolbox') }</div>
<div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div>
<div className="mx_DevTools_label_bottom" />
<div className="mx_Dialog_content">
{ Entries.map((Entry) => {
const label = Entry.getLabel();
const onClick = this._setMode(Entry);
return <button className={classes} key={label} onClick={onClick}>{ label }</button>;
}) }
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onCancel}>{ _t('Cancel') }</button>
</div>
</div>;
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} title={_t('Developer Tools')}>
{ body }
</BaseDialog>
);
}
}

View file

@ -52,22 +52,18 @@ export default React.createClass({
};
},
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={this.props.title || _t('Error')}>
<div className="mx_Dialog_content">
title={this.props.title || _t('Error')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description || _t('An error has occurred.') }
</div>
<div className="mx_Dialog_buttons">
<button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}>
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
{ this.props.button || _t('OK') }
</button>
</div>

View file

@ -73,11 +73,12 @@ export default React.createClass({
let content;
if (this.state.authError) {
content = (
<div>
<div>{ this.state.authError.message || this.state.authError.toString() }</div>
<div id='mx_Dialog_content'>
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
<br />
<AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button"
autoFocus="true"
>
{ _t("Dismiss") }
</AccessibleButton>
@ -85,7 +86,7 @@ export default React.createClass({
);
} else {
content = (
<div>
<div id='mx_Dialog_content'>
<InteractiveAuth ref={this._collectInteractiveAuth}
matrixClient={this.props.matrixClient}
authData={this.props.authData}
@ -100,6 +101,7 @@ export default React.createClass({
<BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished}
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -126,11 +126,11 @@ export default React.createClass({
text = _t(text, {displayName: displayName});
return (
<div>
<div id='mx_Dialog_content'>
<p>{ text }</p>
<div className="mx_Dialog_buttons">
<button onClick={this._onVerifyClicked}>
<button onClick={this._onVerifyClicked} autoFocus="true">
{ _t('Start verification') }
</button>
<button onClick={this._onShareClicked}>
@ -154,7 +154,7 @@ export default React.createClass({
content = this._renderContent();
} else {
content = (
<div>
<div id='mx_Dialog_content'>
<p>{ _t('Loading device info...') }</p>
<Spinner />
</div>
@ -165,6 +165,7 @@ export default React.createClass({
<BaseDialog className='mx_KeyShareRequestDialog'
onFinished={this.props.onFinished}
title={_t('Encryption key request')}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -60,13 +60,14 @@ export default React.createClass({
}
return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
cancelButton={this.props.cancelButton}
onPrimaryButtonClick={this.onOk}
primaryButtonClass={primaryButtonClass}
focus={this.props.focus}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -35,44 +36,74 @@ export default React.createClass({
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
},
_continueClicked: function() {
this.props.onFinished(true);
_onClearStorageClick: function() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, {
title: _t("Sign out"),
description:
<div>{ _t("Log out and remove encryption keys?") }</div>,
button: _t("Sign out"),
danger: true,
onFinished: this.props.onFinished,
});
},
_onRefreshClick: function() {
// Is this likely to help? Probably not, but giving only one button
// that clears your storage seems awful.
window.location.reload(true);
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let bugreport;
const clearStorageButton = (
<button onClick={this._onClearStorageClick} className="danger">
{ _t("Clear Storage and Sign Out") }
</button>
);
let dialogButtons;
if (SdkConfig.get().bug_report_endpoint_url) {
bugreport = (
<p>
{ _t(
"Otherwise, <a>click here</a> to send a bug report.",
{},
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
) }
</p>
);
dialogButtons = <DialogButtons primaryButton={_t("Send Logs")}
onPrimaryButtonClick={this._sendBugReport}
focus={true}
hasCancel={false}
>
{ clearStorageButton }
</DialogButtons>;
} else {
dialogButtons = <DialogButtons primaryButton={_t("Refresh")}
onPrimaryButtonClick={this._onRefreshClick}
focus={true}
hasCancel={false}
>
{ clearStorageButton }
</DialogButtons>;
}
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={_t('Unable to restore session')}>
<div className="mx_Dialog_content">
<p>{ _t("We encountered an error trying to restore your previous session. If " +
"you continue, you will need to log in again, and encrypted chat " +
"history will be unreadable.") }</p>
title={_t('Unable to restore session')}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>{ _t("We encountered an error trying to restore your previous session.") }</p>
<p>{ _t("If you have previously used a more recent version of Riot, your session " +
"may be incompatible with this version. Close this window and return " +
"to the more recent version.") }</p>
<p>{ _t(
"If you have previously used a more recent version of Riot, your session " +
"may be incompatible with this version. Close this window and return " +
"to the more recent version.",
) }</p>
{ bugreport }
<p>{ _t(
"Clearing your browser's storage may fix the problem, but will sign you " +
"out and cause any encrypted chat history to become unreadable.",
) }</p>
</div>
<DialogButtons primaryButton={_t("Continue anyway")}
onPrimaryButtonClick={this._continueClicked}
onCancel={this.props.onFinished} />
{ dialogButtons }
</BaseDialog>
);
},

View file

@ -41,9 +41,6 @@ export default React.createClass({
};
},
componentDidMount: function() {
},
onEmailAddressChanged: function(value) {
this.setState({
emailAddress: value,
@ -131,6 +128,7 @@ export default React.createClass({
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
className="mx_SetEmailDialog_email_input"
autoFocus="true"
placeholder={_t("Email address")}
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
blurToCancel={false}
@ -140,9 +138,10 @@ export default React.createClass({
<BaseDialog className="mx_SetEmailDialog"
onFinished={this.onCancelled}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<p>
<p id='mx_Dialog_content'>
{ _t('This will allow you to reset your password and receive notifications.') }
</p>
{ emailInput }

View file

@ -235,14 +235,14 @@ export default React.createClass({
"error": Boolean(this.state.usernameError),
"success": usernameAvailable,
});
usernameIndicator = <div className={usernameIndicatorClasses}>
usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
{ usernameAvailable ? _t('Username available') : this.state.usernameError }
</div>;
}
let authErrorIndicator = null;
if (this.state.authError) {
authErrorIndicator = <div className="error">
authErrorIndicator = <div className="error" role="alert">
{ this.state.authError }
</div>;
}
@ -254,8 +254,9 @@ export default React.createClass({
<BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished}
title={_t('To get started, please pick a username!')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<div className="mx_SetMxIdDialog_input_group">
<input type="text" ref="input_value" value={this.state.username}
autoFocus={true}

View file

@ -0,0 +1,137 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
const WarmFuzzy = function(props) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let title = _t('You have successfully set a password!');
if (props.didSetEmail) {
title = _t('You have successfully set a password and an email address!');
}
const advice = _t('You can now return to your account after signing out, and sign in on other devices.');
let extraAdvice = null;
if (!props.didSetEmail) {
extraAdvice = _t('Remember, you can always set an email address in user settings if you change your mind.');
}
return <BaseDialog className="mx_SetPasswordDialog"
onFinished={props.onFinished}
title={ title }
>
<div className="mx_Dialog_content">
<p>
{ advice }
</p>
<p>
{ extraAdvice }
</p>
</div>
<div className="mx_Dialog_buttons">
<button
className="mx_Dialog_primary"
autoFocus={true}
onClick={props.onFinished}>
{ _t('Continue') }
</button>
</div>
</BaseDialog>;
};
/**
* Prompt the user to set a password
*
* On success, `onFinished()` when finished
*/
export default React.createClass({
displayName: 'SetPasswordDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
error: null,
};
},
componentWillMount: function() {
console.info('SetPasswordDialog component will mount');
},
_onPasswordChanged: function(res) {
Modal.createDialog(WarmFuzzy, {
didSetEmail: res.didSetEmail,
onFinished: () => {
this._onContinueClicked();
},
});
},
_onContinueClicked: function() {
this.props.onFinished(true);
},
_onPasswordChangeError: function(err) {
let errMsg = err.error || "";
if (err.httpStatus === 403) {
errMsg = _t('Failed to change password. Is your password correct?');
} else if (err.httpStatus) {
errMsg += ' ' + _t(
'(HTTP status %(httpStatus)s)',
{ httpStatus: err.httpStatus },
);
}
this.setState({
error: errMsg,
});
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const ChangePassword = sdk.getComponent('views.settings.ChangePassword');
return (
<BaseDialog className="mx_SetPasswordDialog"
onFinished={this.props.onFinished}
title={ _t('Please set a password!') }
>
<div className="mx_Dialog_content">
<p>
{ _t('This will allow you to return to your account after signing out, and sign in on other devices.') }
</p>
<ChangePassword
className="mx_SetPasswordDialog_change_password"
rowClassName=""
rowLabelClassName=""
rowInputClassName=""
buttonClassName="mx_Dialog_primary mx_SetPasswordDialog_change_password_button"
confirm={false}
autoFocusNewPasswordInput={true}
shouldAskForEmail={true}
onError={this._onPasswordChangeError}
onFinished={this._onPasswordChanged} />
<div className="error">
{ this.state.error }
</div>
</div>
</BaseDialog>
);
},
});

View file

@ -61,17 +61,18 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> { this.props.description } </label>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> { this.props.description } </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
</div>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
</div>
</div>
</form>
<DialogButtons primaryButton={this.props.button}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} />

View file

@ -146,6 +146,7 @@ export default React.createClass({
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
@ -189,8 +190,9 @@ export default React.createClass({
<BaseDialog className='mx_UnknownDeviceDialog'
onFinished={this.props.onFinished}
title={_t('Room contains unknown devices')}
contentId='mx_Dialog_content'
>
<GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
<h4>
{ _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4>
@ -198,7 +200,7 @@ export default React.createClass({
{ _t("Unknown devices") }:
<UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbar>
</GeminiScrollbarWrapper>
<DialogButtons primaryButton={sendButtonLabel}
onPrimaryButtonClick={sendButtonOnClick}
onCancel={this._onDismissClicked} />

View file

@ -0,0 +1,254 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
const DEFAULT_ICON_URL = "img/network-matrix.svg";
export default class NetworkDropdown extends React.Component {
constructor(props) {
super(props);
this.dropdownRootElement = null;
this.ignoreEvent = null;
this.onInputClick = this.onInputClick.bind(this);
this.onRootClick = this.onRootClick.bind(this);
this.onDocumentClick = this.onDocumentClick.bind(this);
this.onMenuOptionClick = this.onMenuOptionClick.bind(this);
this.onInputKeyUp = this.onInputKeyUp.bind(this);
this.collectRoot = this.collectRoot.bind(this);
this.collectInputTextBox = this.collectInputTextBox.bind(this);
this.inputTextBox = null;
const server = MatrixClientPeg.getHomeServerName();
this.state = {
expanded: false,
selectedServer: server,
selectedInstance: null,
includeAllNetworks: false,
};
}
componentWillMount() {
// Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else
document.addEventListener('click', this.onDocumentClick, false);
// fire this now so the defaults can be set up
this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks);
}
componentWillUnmount() {
document.removeEventListener('click', this.onDocumentClick, false);
}
componentDidUpdate() {
if (this.state.expanded && this.inputTextBox) {
this.inputTextBox.focus();
}
}
onDocumentClick(ev) {
// Close the dropdown if the user clicks anywhere that isn't
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
}
onRootClick(ev) {
// This captures any clicks that happen within our elements,
// such that we can then ignore them when they're seen by the
// click listener on the document handler, ie. not close the
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
}
onInputClick(ev) {
this.setState({
expanded: !this.state.expanded,
});
ev.preventDefault();
}
onMenuOptionClick(server, instance, includeAll) {
this.setState({
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAll: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key == 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
});
this.props.onOptionChange(e.target.value, null);
}
}
collectRoot(e) {
if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
}
if (e) {
e.addEventListener('click', this.onRootClick, false);
}
this.dropdownRootElement = e;
}
collectInputTextBox(e) {
this.inputTextBox = e;
}
_getMenuOptions() {
const options = [];
let servers = [];
if (this.props.config.servers) {
servers = servers.concat(this.props.config.servers);
}
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {
servers.unshift(MatrixClientPeg.getHomeServerName());
}
// For our own HS, we can use the instance_ids given in the third party protocols
// response to get the server to filter the room list by network for us.
// We can't get thirdparty protocols for remote server yet though, so for those
// we can only show the default room list.
for (const server of servers) {
options.push(this._makeMenuOption(server, null, true));
if (server == MatrixClientPeg.getHomeServerName()) {
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
if (!this.props.protocols[proto].instances) continue;
const sortedInstances = this.props.protocols[proto].instances;
sortedInstances.sort(function(x, y) {
const a = x.desc
const b = y.desc
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
});
for (const instance of sortedInstances) {
if (!instance.instance_id) continue;
options.push(this._makeMenuOption(server, instance, false));
}
}
}
}
}
return options;
}
_makeMenuOption(server, instance, includeAll, handleClicks) {
if (handleClicks === undefined) handleClicks = true;
let icon;
let name;
let span_class;
let key;
if (!instance && includeAll) {
key = server;
name = server;
span_class = 'mx_NetworkDropdown_menu_all';
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src="img/network-matrix.svg" />;
span_class = 'mx_NetworkDropdown_menu_network';
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
span_class = 'mx_NetworkDropdown_menu_network';
}
const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={click_handler}>
{icon}
<span className="mx_NetworkDropdown_menu_network">{name}</span>
</div>
}
render() {
let current_value;
let menu;
if (this.state.expanded) {
const menu_options = this._getMenuOptions();
menu = <div className="mx_NetworkDropdown_menu">
{menu_options}
</div>;
current_value = <input type="text" className="mx_NetworkDropdown_networkoption"
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
placeholder="matrix.org" // 'matrix.org' as an example of an HS name
/>
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
current_value = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAll, false
);
}
return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
<div className="mx_NetworkDropdown_input" onClick={this.onInputClick}>
{current_value}
<span className="mx_NetworkDropdown_arrow"></span>
{menu}
</div>
</div>;
}
}
NetworkDropdown.propTypes = {
onOptionChange: React.PropTypes.func.isRequired,
protocols: React.PropTypes.object,
// The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
config: React.PropTypes.object,
};
NetworkDropdown.defaultProps = {
protocols: {},
config: {},
};

View file

@ -17,6 +17,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { KeyCode } from '../../../Keyboard';
/**
* AccessibleButton is a generic wrapper for any element that should be treated
* as a button. Identifies the element as a button, setting proper tab
@ -28,8 +30,34 @@ import PropTypes from 'prop-types';
export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
// It's because we are using html buttons at a few places e.g. inside dialogs
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
restProps.onKeyDown = function(e) {
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";

View file

@ -52,6 +52,10 @@ export default class AppPermission extends React.Component {
e2eWarningText =
<span className='mx_AppPermissionWarningTextLabel'>{ _t('NOTE: Apps are not end-to-end encrypted') }</span>;
}
const cookieWarning =
<span className='mx_AppPermissionWarningTextLabel'>
{ _t('Warning: This widget might use cookies.') }
</span>;
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
@ -60,6 +64,7 @@ export default class AppPermission extends React.Component {
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{ _t('Do you want to load widget from URL:') }</span> <span className='mx_AppPermissionWarningTextURL'>{ this.state.curlBase }</span>
{ e2eWarningText }
{ cookieWarning }
</div>
<input
className='mx_AppPermissionButton'

View file

@ -1,4 +1,4 @@
/*
/**
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
@ -36,32 +36,26 @@ import WidgetUtils from '../../../WidgetUtils';
import dis from '../../../dispatcher';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;
export default React.createClass({
displayName: 'AppTile',
export default class AppTile extends React.Component {
constructor(props) {
super(props);
this.state = this._getNewState(props);
propTypes: {
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
},
getDefaultProps() {
return {
url: "",
waitForIframeLoad: true,
};
},
this._onWidgetAction = this._onWidgetAction.bind(this);
this._onMessage = this._onMessage.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onSnapshotClick = this._onSnapshotClick.bind(this);
this.onClickMenuBar = this.onClickMenuBar.bind(this);
this._onMinimiseClick = this._onMinimiseClick.bind(this);
this._onInitialLoad = this._onInitialLoad.bind(this);
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
}
/**
* Set initial component state when the App wUrl (widget URL) is being updated.
@ -73,8 +67,8 @@ export default React.createClass({
const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return {
initialising: true, // True while we are mangling the widget URL
loading: this.props.waitForIframeLoad, // True while the iframe content is loading
initialising: true, // True while we are mangling the widget URL
loading: this.props.waitForIframeLoad, // True while the iframe content is loading
widgetUrl: this._addWurlParams(newProps.url),
widgetPermissionId: widgetPermissionId,
// Assume that widget has permission to load if we are the user who
@ -83,8 +77,20 @@ export default React.createClass({
error: null,
deleting: false,
widgetPageTitle: newProps.widgetPageTitle,
allowedCapabilities: (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) ?
this.props.whitelistCapabilities : [],
requestedCapabilities: [],
};
},
}
/**
* Does the widget support a given capability
* @param {string} capability Capability to check for
* @return {Boolean} True if capability supported
*/
_hasCapability(capability) {
return this.state.allowedCapabilities.some((c) => {return c === capability;});
}
/**
* Add widget instance specific parameters to pass in wUrl
@ -112,11 +118,7 @@ export default React.createClass({
u.query = params;
return u.format();
},
getInitialState() {
return this._getNewState(this.props);
},
}
/**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
@ -140,7 +142,7 @@ export default React.createClass({
}
}
return false;
},
}
isMixedContent() {
const parentContentProtocol = window.location.protocol;
@ -152,14 +154,43 @@ export default React.createClass({
return true;
}
return false;
},
}
componentWillMount() {
WidgetMessaging.startListening();
WidgetMessaging.addEndpoint(this.props.id, this.props.url);
window.addEventListener('message', this._onMessage, false);
this.setScalarToken();
},
}
componentDidMount() {
// Legacy Jitsi widget messaging -- TODO replace this with standard widget
// postMessaging API
window.addEventListener('message', this._onMessage, false);
// Widget action listeners
this.dispatcherRef = dis.register(this._onWidgetAction);
}
componentDidUpdate() {
// Allow parents to access widget messaging
if (this.props.collectWidgetMessaging) {
this.props.collectWidgetMessaging(this.widgetMessaging);
}
}
componentWillUnmount() {
// Widget action listeners
dis.unregister(this.dispatcherRef);
// Widget postMessage listeners
try {
if (this.widgetMessaging) {
this.widgetMessaging.stop();
}
} catch (e) {
console.error('Failed to stop listening for widgetMessaging events', e.message);
}
// Jitsi listener
window.removeEventListener('message', this._onMessage);
}
/**
* Adds a scalar token to the widget URL, if required
@ -211,13 +242,7 @@ export default React.createClass({
initialising: false,
});
});
},
componentWillUnmount() {
WidgetMessaging.stopListening();
WidgetMessaging.removeEndpoint(this.props.id, this.props.url);
window.removeEventListener('message', this._onMessage);
},
}
componentWillReceiveProps(nextProps) {
if (nextProps.url !== this.props.url) {
@ -232,8 +257,10 @@ export default React.createClass({
widgetPageTitle: nextProps.widgetPageTitle,
});
}
},
}
// Legacy Jitsi widget messaging
// TODO -- This should be replaced with the new widget postMessaging API
_onMessage(event) {
if (this.props.type !== 'jitsi') {
return;
@ -251,63 +278,145 @@ export default React.createClass({
.document.querySelector('iframe[id^="jitsiConferenceFrame"]');
PlatformPeg.get().setupScreenSharingForIframe(iframe);
}
},
}
_canUserModify() {
// User widgets should always be modifiable by their creator
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
return true;
}
// Check if the current user can modify widgets in the current room
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
},
}
_onEditClick(e) {
console.log("Edit widget ID ", this.props.id);
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room.roomId, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
}
}
_onSnapshotClick(e) {
console.warn("Requesting widget snapshot");
this.widgetMessaging.getScreenshot()
.catch((err) => {
console.error("Failed to get screenshot", err);
})
.then((screenshot) => {
dis.dispatch({
action: 'picture_snapshot',
file: screenshot,
}, true);
});
}
/* If user has permission to modify widgets, delete the widget,
* otherwise revoke access for the widget to load in the user's browser
*/
_onDeleteClick() {
if (this._canUserModify()) {
// Show delete confirmation dialog
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) {
return;
}
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).catch((e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
},
});
if (this.props.onDeleteClick) {
this.props.onDeleteClick();
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
if (this._canUserModify()) {
// Show delete confirmation dialog
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) {
return;
}
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).catch((e) => {
console.error('Failed to delete widget', e);
}).finally(() => {
this.setState({deleting: false});
});
},
});
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
}
},
}
/**
* Called when widget iframe has finished loading
*/
_onLoaded() {
if (!this.widgetMessaging) {
this._onInitialLoad();
}
this.setState({loading: false});
},
}
/**
* Called on initial load of the widget iframe
*/
_onInitialLoad() {
this.widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
this.widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.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.warn(`Widget ${this.props.id} allowing requested, whitelisted properties:`,
requestedWhitelistCapabilies);
}
}
// TODO -- Add UI to warn about and optionally allow requested capabilities
this.setState({
requestedCapabilities,
allowedCapabilities: this.state.allowedCapabilities.concat(requestedWhitelistCapabilies),
});
if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
});
}
_onWidgetAction(payload) {
if (payload.widgetId === this.props.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;
}
}
}
/**
* Set remote content title on AppTile
@ -321,7 +430,7 @@ export default React.createClass({
}, (err) =>{
console.error("Failed to get page title", err);
});
},
}
// Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method
@ -330,20 +439,20 @@ export default React.createClass({
return _td('Delete widget');
}
return _td('Revoke widget access');
},
}
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true});
},
}
_revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false});
},
}
formatAppTileName() {
let appTileName = "No name";
@ -351,7 +460,7 @@ export default React.createClass({
appTileName = this.props.name.trim();
}
return appTileName;
},
}
onClickMenuBar(ev) {
ev.preventDefault();
@ -366,16 +475,49 @@ export default React.createClass({
action: 'appsDrawer',
show: !this.props.show,
});
},
}
_getSafeUrl() {
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
const parsedWidgetUrl = url.parse(this.state.widgetUrl, true);
if (ENABLE_REACT_PERF) {
parsedWidgetUrl.search = null;
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
return safeWidgetUrl;
},
}
_getTileTitle() {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
title = this.state.widgetPageTitle;
}
return (
<span>
<b>{ name }</b>
<span>{ title ? titleSpacer : '' }{ title }</span>
</span>
);
}
_onMinimiseClick(e) {
if (this.props.onMinimiseClick) {
this.props.onMinimiseClick();
}
}
_onPopoutWidgetClick(e) {
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes,noreferrer=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener noreferrer'}).click();
}
render() {
let appTileBody;
@ -393,14 +535,22 @@ export default React.createClass({
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation";
// 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;";
if (this.props.show) {
const loadingElement = (
<div className='mx_AppTileBody mx_AppLoading'>
<div className="mx_AppLoading_spinner_fadeIn">
<MessageSpinner msg='Loading...' />
</div>
);
if (this.state.initialising) {
appTileBody = loadingElement;
appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
</div>
);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
@ -410,9 +560,15 @@ export default React.createClass({
);
} else {
appTileBody = (
<div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}>
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement }
{ /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
"allow" attribute, which is unknown to react 15.
*/ }
<iframe
is
allow={iframeFeatures}
ref="appFrame"
src={this._getSafeUrl()}
allowFullScreen="true"
@ -446,29 +602,53 @@ export default React.createClass({
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
}
// Picture snapshot - only show button when apps are maximised.
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const showPictureSnapshotIcon = 'img/camera_green.svg';
const popoutWidgetIcon = 'img/button-new-window.svg';
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg');
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
{ this.props.showMenubar &&
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle">
<TintableSvgButton
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
{ this.props.showMinimise && <TintableSvgButton
src={windowStateIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Minimize apps')}
width="10"
height="10"
/>
<b>{ this.formatAppTileName() }</b>
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && (
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span>
) }
onClick={this._onMinimiseClick}
/> }
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ /* Popout widget */ }
{ this.props.showPopout && <TintableSvgButton
src={popoutWidgetIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
width="10"
height="10"
/> }
{ /* Snapshot widget */ }
{ showPictureSnapshotButton && <TintableSvgButton
src={showPictureSnapshotIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Picture')}
onClick={this._onSnapshotClick}
width="10"
height="10"
/> }
{ /* Edit widget */ }
{ showEditButton && <TintableSvgButton
src="img/edit_green.svg"
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
className={"mx_AppTileMenuBarWidget " +
(this.props.showDelete ? "mx_AppTileMenuBarWidgetPadding" : "")}
title={_t('Edit')}
onClick={this._onEditClick}
width="10"
@ -476,18 +656,77 @@ export default React.createClass({
/> }
{ /* Delete widget */ }
<TintableSvgButton
{ this.props.showDelete && <TintableSvgButton
src={deleteIcon}
className={deleteClasses}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
width="10"
height="10"
/>
/> }
</span>
</div>
</div> }
{ appTileBody }
</div>
);
},
});
}
}
AppTile.displayName ='AppTile';
AppTile.propTypes = {
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool,
// Should the AppTile render itself
show: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick: PropTypes.func,
// Optional onMinimiseClickHandler
onMinimiseClick: PropTypes.func,
// Optionally hide the tile title
showTitle: PropTypes.bool,
// Optionally hide the tile minimise icon
showMinimise: PropTypes.bool,
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the delete icon
showDelete: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Widget capabilities to allow by default (without user confirmation)
// 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,
};
AppTile.defaultProps = {
url: "",
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showMinimise: true,
showDelete: true,
showPopout: true,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,
};

View file

@ -15,71 +15,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DragSource, DropTarget } from 'react-dnd';
import TagTile from './TagTile';
import dis from '../../../dispatcher';
import { findDOMNode } from 'react-dom';
const tagTileSource = {
canDrag: function(props, monitor) {
return true;
},
import { Draggable } from 'react-beautiful-dnd';
beginDrag: function(props) {
// Return the data describing the dragged item
return {
tag: props.tag,
};
},
endDrag: function(props, monitor, component) {
const dropResult = monitor.getDropResult();
if (!monitor.didDrop() || !dropResult) {
return;
}
props.onEndDrag();
},
};
const tagTileTarget = {
canDrop(props, monitor) {
return true;
},
hover(props, monitor, component) {
if (!monitor.canDrop()) return;
const draggedY = monitor.getClientOffset().y;
const {top, bottom} = findDOMNode(component).getBoundingClientRect();
const targetY = (top + bottom) / 2;
dis.dispatch({
action: 'order_tag',
tag: monitor.getItem().tag,
targetTag: props.tag,
// Note: we indicate that the tag should be after the target when
// it's being dragged over the top half of the target.
after: draggedY < targetY,
});
},
drop(props) {
// Return the data to be returned by getDropResult
return {
tag: props.tag,
};
},
};
export default
DropTarget('TagTile', tagTileTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
}))(DragSource('TagTile', tagTileSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
}))((props) => {
const { connectDropTarget, connectDragSource, ...otherProps } = props;
return connectDropTarget(connectDragSource(
<div>
<TagTile {...otherProps} />
</div>,
));
}));
export default function DNDTagTile(props) {
return <div>
<Draggable
key={props.tag}
draggableId={props.tag}
index={props.index}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TagTile {...props} />
</div>
{ provided.placeholder }
</div>
) }
</Draggable>
</div>;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Aidan Gauland
Copyright 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use strict";
import React from "react";
import PropTypes from "prop-types";
import { _t } from '../../../languageHandler';
@ -30,13 +29,32 @@ module.exports = React.createClass({
// The primary button which is styled differently and has default focus.
primaryButton: PropTypes.node.isRequired,
// A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func.isRequired,
// should there be a cancel button? default: true
hasCancel: PropTypes.bool,
// onClick handler for the cancel button.
onCancel: PropTypes.func.isRequired,
onCancel: PropTypes.func,
focus: PropTypes.bool,
disabled: PropTypes.bool,
},
getDefaultProps: function() {
return {
hasCancel: true,
disabled: false,
};
},
_onCancelClick: function() {
this.props.onCancel();
},
render: function() {
@ -44,18 +62,23 @@ module.exports = React.createClass({
if (this.props.primaryButtonClass) {
primaryButtonClassName += " " + this.props.primaryButtonClass;
}
let cancelButton;
if (this.props.cancelButton || this.props.hasCancel) {
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}>
{ this.props.cancelButton || _t("Cancel") }
</button>;
}
return (
<div className="mx_Dialog_buttons">
<button className={primaryButtonClassName}
onClick={this.props.onPrimaryButtonClick}
autoFocus={this.props.focus}
disabled={this.props.disabled}
>
{ this.props.primaryButton }
</button>
{ this.props.children }
<button onClick={this.props.onCancel}>
{ _t("Cancel") }
</button>
{ cancelButton }
</div>
);
},

View file

@ -107,7 +107,11 @@ export default class Flair extends React.Component {
}
const profiles = await this._getGroupProfiles(groups);
if (!this.unmounted) {
this.setState({profiles: profiles.filter((profile) => {return profile.avatarUrl;})});
this.setState({
profiles: profiles.filter((profile) => {
return profile ? profile.avatarUrl : false;
}),
});
}
}

View file

@ -0,0 +1,35 @@
/*
Copyright 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import GeminiScrollbar from 'react-gemini-scrollbar';
function GeminiScrollbarWrapper(props) {
const {wrappedRef, ...wrappedProps} = props;
// Enable forceGemini so that gemini is always enabled. This is
// to avoid future issues where a feature is implemented without
// doing QA on every OS/browser combination.
//
// By default GeminiScrollbar allows native scrollbars to be used
// on macOS. Use forceGemini to enable Gemini's non-native
// scrollbars on all OSs.
return <GeminiScrollbar ref={wrappedRef} forceGemini={true} {...wrappedProps}>
{ props.children }
</GeminiScrollbar>;
}
export default GeminiScrollbarWrapper;

View file

@ -0,0 +1,205 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
import {formatDate} from '../../../DateUtils';
var filesize = require('filesize');
var AccessibleButton = require('../../../components/views/elements/AccessibleButton');
const Modal = require('../../../Modal');
const sdk = require('../../../index');
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'ImageView',
propTypes: {
src: React.PropTypes.string.isRequired, // the source of the image being displayed
name: React.PropTypes.string, // the main title ('name') for the image
link: React.PropTypes.string, // the link (if any) applied to the name of the image
width: React.PropTypes.number, // width of the image src in pixels
height: React.PropTypes.number, // height of the image src in pixels
fileSize: React.PropTypes.number, // size of the image src in bytes
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
// the event (if any) that the Image is displaying. Used for event-specific stuff like
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
// properties above, which let us use lightboxes to display images which aren't associated
// with events.
mxEvent: React.PropTypes.object,
},
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
// dialog base class somehow, surely...
componentDidMount: function() {
document.addEventListener("keydown", this.onKeyDown);
},
componentWillUnmount: function() {
document.removeEventListener("keydown", this.onKeyDown);
},
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
},
onRedactClick: function() {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
onFinished: (proceed) => {
if (!proceed) return;
var self = this;
MatrixClientPeg.get().redactEvent(
this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()
).catch(function(e) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this.
var code = e.errcode || e.statusCode;
Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this image. (%(code)s)', {code: code})
});
}).done();
}
});
},
getName: function () {
var name = this.props.name;
if (name && this.props.link) {
name = <a href={ this.props.link } target="_blank" rel="noopener">{ name }</a>;
}
return name;
},
render: function() {
/*
// In theory max-width: 80%, max-height: 80% on the CSS should work
// but in practice, it doesn't, so do it manually:
var width = this.props.width || 500;
var height = this.props.height || 500;
var maxWidth = document.documentElement.clientWidth * 0.8;
var maxHeight = document.documentElement.clientHeight * 0.8;
var widthFrac = width / maxWidth;
var heightFrac = height / maxHeight;
var displayWidth;
var displayHeight;
if (widthFrac > heightFrac) {
displayWidth = Math.min(width, maxWidth);
displayHeight = (displayWidth / width) * height;
} else {
displayHeight = Math.min(height, maxHeight);
displayWidth = (displayHeight / height) * width;
}
var style = {
width: displayWidth,
height: displayHeight
};
*/
var style, res;
if (this.props.width && this.props.height) {
style = {
width: this.props.width,
height: this.props.height,
};
res = style.width + "x" + style.height + "px";
}
var size;
if (this.props.fileSize) {
size = filesize(this.props.fileSize);
}
var size_res;
if (size && res) {
size_res = size + ", " + res;
}
else {
size_res = size || res;
}
var showEventMeta = !!this.props.mxEvent;
var eventMeta;
if(showEventMeta) {
// Figure out the sender, defaulting to mxid
let sender = this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (room) {
const member = room.getMember(sender);
if (member) sender = member.name;
}
eventMeta = (<div className="mx_ImageView_metadata">
{ _t('Uploaded on %(date)s by %(user)s', {date: formatDate(new Date(this.props.mxEvent.getTs())), user: sender}) }
</div>);
}
var eventRedact;
if(showEventMeta) {
eventRedact = (<div className="mx_ImageView_button" onClick={this.onRedactClick}>
{ _t('Remove') }
</div>);
}
return (
<div className="mx_ImageView">
<div className="mx_ImageView_lhs">
</div>
<div className="mx_ImageView_content">
<img src={this.props.src} style={style}/>
<div className="mx_ImageView_labelWrapper">
<div className="mx_ImageView_label">
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }><img src="img/cancel-white.svg" width="18" height="18" alt={ _t('Close') }/></AccessibleButton>
<div className="mx_ImageView_shim">
</div>
<div className="mx_ImageView_name">
{ this.getName() }
</div>
{ eventMeta }
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
<div className="mx_ImageView_download">
{ _t('Download this file') }<br/>
<span className="mx_ImageView_size">{ size_res }</span>
</div>
</a>
{ eventRedact }
<div className="mx_ImageView_shim">
</div>
</div>
</div>
</div>
<div className="mx_ImageView_rhs">
</div>
</div>
);
}
});

View file

@ -0,0 +1,33 @@
/*
Copyright 2017 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
module.exports = React.createClass({
displayName: 'InlineSpinner',
render: function() {
var w = this.props.w || 16;
var h = this.props.h || 16;
var imgClass = this.props.imgClassName || "";
return (
<div className="mx_InlineSpinner">
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
</div>
);
}
});

View file

@ -64,7 +64,7 @@ export default class ManageIntegsButton extends React.Component {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createDialog(IntegrationsManager, {
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.roomId) :
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
null,
}, "mx_IntegrationsManager");
}
@ -103,5 +103,5 @@ export default class ManageIntegsButton extends React.Component {
}
ManageIntegsButton.propTypes = {
roomId: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
};

View file

@ -0,0 +1,114 @@
/*
Copyright 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
const ReactDOM = require('react-dom');
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
const ContainerId = "mx_PersistedElement";
function getOrCreateContainer() {
let container = document.getElementById(ContainerId);
if (!container) {
container = document.createElement("div");
container.id = ContainerId;
document.body.appendChild(container);
}
return container;
}
// Greater than that of the ContextualMenu
const PE_Z_INDEX = 3000;
/*
* Class of component that renders its children in a separate ReactDOM virtual tree
* in a container element appended to document.body.
*
* This prevents the children from being unmounted when the parent of PersistedElement
* unmounts, allowing them to persist.
*
* When PE is unmounted, it hides the children using CSS. When mounted or updated, the
* children are made visible and are positioned into a div that is given the same
* bounding rect as the parent of PE.
*/
export default class PersistedElement extends React.Component {
constructor() {
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this);
}
collectChildContainer(ref) {
this.childContainer = ref;
}
collectChild(ref) {
this.child = ref;
this.updateChild();
}
componentDidMount() {
this.updateChild();
}
componentDidUpdate() {
this.updateChild();
}
componentWillUnmount() {
this.updateChildVisibility(this.child, false);
}
updateChild() {
this.updateChildPosition(this.child, this.childContainer);
this.updateChildVisibility(this.child, true);
}
updateChildVisibility(child, visible) {
if (!child) return;
child.style.display = visible ? 'block' : 'none';
}
updateChildPosition(child, parent) {
if (!child || !parent) return;
const parentRect = parent.getBoundingClientRect();
Object.assign(child.style, {
position: 'absolute',
top: parentRect.top + 'px',
left: parentRect.left + 'px',
width: parentRect.width + 'px',
height: parentRect.height + 'px',
zIndex: PE_Z_INDEX,
});
}
render() {
const content = <div ref={this.collectChild}>
{this.props.children}
</div>;
ReactDOM.render(content, getOrCreateContainer());
return <div ref={this.collectChildContainer}></div>;
}
}

View file

@ -17,7 +17,7 @@ import React from 'react';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import classNames from 'classnames';
import { Room, RoomMember } from 'matrix-js-sdk';
import { Room, RoomMember, MatrixClient } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
@ -61,6 +61,17 @@ const Pill = React.createClass({
shouldShowPillAvatar: PropTypes.bool,
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getChildContext() {
return {
matrixClient: this._matrixClient,
};
},
getInitialState() {
return {
// ID/alias of the room/user
@ -135,6 +146,7 @@ const Pill = React.createClass({
componentWillMount() {
this._unmounted = false;
this._matrixClient = MatrixClientPeg.get();
this.componentWillReceiveProps(this.props);
},
@ -195,6 +207,7 @@ const Pill = React.createClass({
const member = this.state.member;
if (member) {
userId = member.userId;
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} />;

View file

@ -42,6 +42,9 @@ module.exports = React.createClass({
// should the user be able to change the value? false by default.
disabled: PropTypes.bool,
onChange: PropTypes.func,
// Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string,
},
getInitialState: function() {
@ -84,17 +87,17 @@ module.exports = React.createClass({
onSelectChange: function(event) {
this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" });
if (event.target.value !== "SELECT_VALUE_CUSTOM") {
this.props.onChange(event.target.value);
this.props.onChange(event.target.value, this.props.powerLevelKey);
}
},
onCustomBlur: function(event) {
this.props.onChange(parseInt(this.refs.custom.value));
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
},
onCustomKeyDown: function(event) {
if (event.key == "Enter") {
this.props.onChange(parseInt(this.refs.custom.value));
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
}
},

View file

@ -1,142 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
// 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_MATRIXTO = /^#\/room\/(([\#\!])[^\/]*)\/(\$[^\/]*)$/;
export default class Quote extends React.Component {
static isMessageUrl(url) {
return !!REGEX_LOCAL_MATRIXTO.exec(url);
}
static childContextTypes = {
matrixClient: PropTypes.object,
};
static propTypes = {
// The matrix.to url of the event
url: PropTypes.string,
// The parent event
parentEv: PropTypes.instanceOf(MatrixEvent),
// Whether this isn't the first Quote, and we're being nested
isNested: PropTypes.bool,
};
constructor(props, context) {
super(props, context);
this.state = {
// The event related to this quote
event: null,
show: !this.props.isNested,
};
this.onQuoteClick = this.onQuoteClick.bind(this);
}
getChildContext() {
return {
matrixClient: MatrixClientPeg.get(),
};
}
componentWillReceiveProps(nextProps) {
let roomId;
let prefix;
let eventId;
if (nextProps.url) {
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = REGEX_LOCAL_MATRIXTO.exec(nextProps.url) || [];
roomId = matrixToMatch[1]; // The room ID
prefix = matrixToMatch[2]; // The first character of prefix
eventId = matrixToMatch[3]; // The event ID
}
const room = prefix === '#' ?
MatrixClientPeg.get().getRooms().find((r) => {
return r.getAliases().includes(roomId);
}) : MatrixClientPeg.get().getRoom(roomId);
// Only try and load the event if we know about the room
// otherwise we just leave a `Quote` anchor which can be used to navigate/join the room manually.
if (room) this.getEvent(room, eventId);
}
componentWillMount() {
this.componentWillReceiveProps(this.props);
}
async getEvent(room, eventId) {
let event = room.findEventById(eventId);
if (event) {
this.setState({room, event});
return;
}
await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
event = room.findEventById(eventId);
this.setState({room, event});
}
onQuoteClick() {
this.setState({
show: true,
});
}
render() {
const ev = this.state.event;
if (ev) {
if (this.state.show) {
const EventTile = sdk.getComponent('views.rooms.EventTile');
let dateSep = null;
const evDate = ev.getDate();
if (wantsDateSeparator(this.props.parentEv.getDate(), evDate)) {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
dateSep = <a href={this.props.url}><DateSeparator ts={evDate} /></a>;
}
return <blockquote className="mx_Quote">
{ dateSep }
<EventTile mxEvent={ev} tileShape="quote" />
</blockquote>;
}
return <div>
<a onClick={this.onQuoteClick} className="mx_Quote_show">{ _t('Quote') }</a>
<br />
</div>;
}
// Deliberately render nothing if the URL isn't recognised
return <div>
<a href={this.props.url}>{ _t('Quote') }</a>
<br />
</div>;
}
}

View file

@ -0,0 +1,306 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
// This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
// be low as each event being loaded (after the first) is triggered by an explicit user action.
export default class ReplyThread extends React.Component {
static propTypes = {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onWidgetLoad: PropTypes.func.isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
constructor(props, context) {
super(props, context);
this.state = {
// The loaded events to be rendered as linear-replies
events: [],
// The latest loaded event which has not yet been shown
loadedEv: null,
// Whether the component is still loading more events
loading: true,
// Whether as error was encountered fetching a replied to event.
err: false,
};
this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this);
}
static getParentEventId(ev) {
if (!ev || ev.isRedacted()) return;
const mRelatesTo = ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
}
}
// Part of Replies fallback support
static stripPlainReply(body) {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === '') lines.shift();
return lines.join('\n');
}
// Part of Replies fallback support
static stripHTMLReply(html) {
return html.replace(/^<mx-reply>[\s\S]+?<\/mx-reply>/, '');
}
// Part of Replies fallback support
static getNestedReplyText(ev) {
if (!ev) return null;
let {body, formatted_body: html} = ev.getContent();
if (this.getParentEventId(ev)) {
if (body) body = this.stripPlainReply(body);
if (html) html = this.stripHTMLReply(html);
}
const evLink = makeEventPermalink(ev.getRoomId(), ev.getId());
const userLink = makeUserPermalink(ev.getSender());
const mxid = ev.getSender();
// This fallback contains text that is explicitly EN.
switch (ev.getContent().msgtype) {
case 'm.text':
case 'm.notice': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html || body}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
case 'm.image':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an image.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an image.\n\n`;
break;
case 'm.video':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a video.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a video.\n\n`;
break;
case 'm.audio':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an audio file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an audio file.\n\n`;
break;
case 'm.file':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a file.\n\n`;
break;
case 'm.emote': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html || body}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
default:
return null;
}
return {body, html};
}
static makeReplyMixIn(ev) {
if (!ev) return {};
return {
'm.relates_to': {
'm.in_reply_to': {
'event_id': ev.getId(),
},
},
};
}
static makeThread(parentEv, onWidgetLoad, ref) {
if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !ReplyThread.getParentEventId(parentEv)) {
return <div />;
}
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;
}
componentWillMount() {
this.unmounted = false;
this.room = this.context.matrixClient.getRoom(this.props.parentEv.getRoomId());
this.initialize();
}
componentDidUpdate() {
this.props.onWidgetLoad();
}
componentWillUnmount() {
this.unmounted = true;
}
async initialize() {
const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return;
if (ev) {
this.setState({
events: [ev],
}, this.loadNextEvent);
} else {
this.setState({err: true});
}
}
async loadNextEvent() {
if (this.unmounted) return;
const ev = this.state.events[0];
const inReplyToEventId = ReplyThread.getParentEventId(ev);
if (!inReplyToEventId) {
this.setState({
loading: false,
});
return;
}
const loadedEv = await this.getEvent(inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else {
this.setState({err: true});
}
}
async getEvent(eventId) {
const event = this.room.findEventById(eventId);
if (event) return event;
try {
// ask the client to fetch the event we want using the context API, only interface to do so is to ask
// for a timeline with that event, but once it is loaded we can use findEventById to look up the ev map
await this.context.matrixClient.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId);
} catch (e) {
// if it fails catch the error and return early, there's no point trying to find the event in this case.
// Return null as it is falsey and thus should be treated as an error (as the event cannot be resolved).
return null;
}
return this.room.findEventById(eventId);
}
canCollapse() {
return this.state.events.length > 1;
}
collapse() {
this.initialize();
}
onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events];
this.setState({
loadedEv: null,
events,
}, this.loadNextEvent);
dis.dispatch({action: 'focus_composer'});
}
render() {
let header = null;
if (this.state.err) {
header = <blockquote className="mx_ReplyThread mx_ReplyThread_error">
{
_t('Unable to load event that was replied to, ' +
'it either does not exist or you do not have permission to view it.')
}
</blockquote>;
} else if (this.state.loadedEv) {
const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.matrixClient.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread">
{
_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} />,
})
}
</blockquote>;
} else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />;
}
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => {
let dateSep = null;
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile mxEvent={ev}
tileShape="reply"
onWidgetLoad={this.props.onWidgetLoad}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</blockquote>;
});
return <div>
<div>{ header }</div>
<div>{ evTiles }</div>
</div>;
}
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
module.exports = React.createClass({
displayName: 'Spinner',
render: function() {
var w = this.props.w || 32;
var h = this.props.h || 32;
var imgClass = this.props.imgClassName || "";
return (
<div className="mx_Spinner">
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
</div>
);
}
});

View file

@ -0,0 +1,53 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {highlightBlock} from 'highlight.js';
export default class SyntaxHighlight extends React.Component {
static propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
constructor(props, context) {
super(props, context);
this._ref = this._ref.bind(this);
}
// componentDidUpdate used here for reusability
// componentWillReceiveProps fires too early to call highlightBlock on.
componentDidUpdate() {
if (this._el) highlightBlock(this._el);
}
// call componentDidUpdate because _ref is fired on initial render
// which does not fire componentDidUpdate
_ref(el) {
this._el = el;
this.componentDidUpdate();
}
render() {
const { className, children } = this.props;
return <pre className={`${className} mx_SyntaxHighlight`} ref={this._ref}>
<code>{ children }</code>
</pre>;
}
}

View file

@ -21,8 +21,10 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import * as ContextualMenu from '../../structures/ContextualMenu';
import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore';
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to:
@ -54,20 +56,36 @@ export default React.createClass({
componentWillMount() {
this.unmounted = false;
if (this.props.tag[0] === '+') {
FlairStore.getGroupProfileCached(
this.context.matrixClient,
this.props.tag,
).then((profile) => {
if (this.unmounted) return;
this.setState({profile});
}).catch((err) => {
console.warn('Could not fetch group profile for ' + this.props.tag, err);
});
FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated);
this._onFlairStoreUpdated();
// 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() {
if (this.unmounted) return;
FlairStore.getGroupProfileCached(
this.context.matrixClient,
this.props.tag,
).then((profile) => {
if (this.unmounted) return;
this.setState({profile});
}).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) {
@ -79,6 +97,39 @@ export default React.createClass({
ctrlOrCmdKey: isOnlyCtrlOrCmdIgnoreShiftKeyEvent(e),
shiftKey: e.shiftKey,
});
if (this.props.tag[0] === '+') {
// New rooms or members may have been added to the group, fetch async
this._refreshGroup(this.props.tag);
}
},
onContextButtonClick: function(e) {
e.preventDefault();
e.stopPropagation();
// Hide the (...) immediately
this.setState({ hover: false });
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = elementRect.right + window.pageXOffset + 3;
const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
const self = this;
ContextualMenu.createMenu(TagTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
tag: this.props.tag,
onFinished: function() {
self.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
},
onMouseOver: function() {
@ -109,10 +160,21 @@ export default React.createClass({
const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<div />;
const contextButton = this.state.hover || this.state.menuDisplayed ?
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
{ "\u00B7\u00B7\u00B7" }
</div> : <div />;
return <AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
<BaseAvatar
name={name}
idName={this.props.tag}
url={httpUrl}
width={avatarHeight}
height={avatarHeight}
/>
{ tip }
{ contextButton }
</div>
</AccessibleButton>;
},

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import TintableSvg from './TintableSvg';
import AccessibleButton from './AccessibleButton';
export default class TintableSvgButton extends React.Component {
@ -39,9 +40,11 @@ export default class TintableSvgButton extends React.Component {
width={this.props.width}
height={this.props.height}
></TintableSvg>
<span
<AccessibleButton
onClick={this.props.onClick}
element='span'
title={this.props.title}
onClick={this.props.onClick} />
/>
</span>
);
}

View file

@ -0,0 +1,90 @@
/*
Copyright 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Analytics from '../../../Analytics';
export default class CookieBar extends React.Component {
static propTypes = {
policyUrl: PropTypes.string,
}
constructor() {
super();
}
onUsageDataClicked() {
Analytics.showDetailsModal();
}
onAccept() {
dis.dispatch({
action: 'accept_cookies',
});
}
onReject() {
dis.dispatch({
action: 'reject_cookies',
});
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const toolbarClasses = "mx_MatrixToolbar";
return (
<div className={toolbarClasses}>
<img className="mx_MatrixToolbar_warning" src="img/warning.svg" width="24" height="23" alt="Warning" />
<div className="mx_MatrixToolbar_content">
{ this.props.policyUrl ? _t(
"Help improve Riot by sending <UsageDataLink>usage data</UsageDataLink>? " +
"This will use a cookie. " +
"(See our <PolicyLink>cookie and privacy policies</PolicyLink>).",
{},
{
'UsageDataLink': (sub) => <a
className="mx_MatrixToolbar_link"
href="javascript:;"
onClick={this.onUsageDataClicked}
>
{ sub }
</a>,
// XXX: We need to link to the page that explains our cookies
'PolicyLink': (sub) => <a
className="mx_MatrixToolbar_link"
target="_blank"
href={this.props.policyUrl}
>
{ sub }
</a>
,
},
) : _t("Help improve Riot by sending usage data? This will use a cookie.") }
</div>
<AccessibleButton element='button' className="mx_MatrixToolbar_action" onClick={this.onAccept}>
{ _t("Yes please") }
</AccessibleButton>
<AccessibleButton className="mx_MatrixToolbar_close" onClick={this.onReject}>
<img src="img/cancel.svg" width="18" height="18" />
</AccessibleButton>
</div>
);
}
}

View file

@ -0,0 +1,46 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import { _t } from '../../../languageHandler';
import Notifier from '../../../Notifier';
import AccessibleButton from '../../../components/views/elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'MatrixToolbar',
hideToolbar: function() {
Notifier.setToolbarHidden(true);
},
onClick: function() {
Notifier.setEnabled(true);
},
render: function() {
return (
<div className="mx_MatrixToolbar">
<img className="mx_MatrixToolbar_warning" src="img/warning.svg" width="24" height="23" alt="Warning"/>
<div className="mx_MatrixToolbar_content">
{ _t('You are not receiving desktop notifications') } <a className="mx_MatrixToolbar_link" onClick={ this.onClick }> { _t('Enable them now') }</a>
</div>
<AccessibleButton className="mx_MatrixToolbar_close" onClick={ this.hideToolbar } ><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
</div>
);
},
});

View file

@ -0,0 +1,107 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import sdk from '../../../index';
import Modal from '../../../Modal';
import PlatformPeg from '../../../PlatformPeg';
import { _t } from '../../../languageHandler';
/**
* Check a version string is compatible with the Changelog
* dialog ([vectorversion]-react-[react-sdk-version]-js-[js-sdk-version])
*/
function checkVersion(ver) {
const parts = ver.split('-');
return parts.length == 5 && parts[1] == 'react' && parts[3] == 'js';
}
export default React.createClass({
propTypes: {
version: React.PropTypes.string.isRequired,
newVersion: React.PropTypes.string.isRequired,
releaseNotes: React.PropTypes.string,
},
displayReleaseNotes: function(releaseNotes) {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Display release notes', '', QuestionDialog, {
title: _t("What's New"),
description: <div className="mx_MatrixToolbar_changelog">{releaseNotes}</div>,
button: _t("Update"),
onFinished: (update) => {
if(update && PlatformPeg.get()) {
PlatformPeg.get().installUpdate();
}
}
});
},
displayChangelog: function() {
const ChangelogDialog = sdk.getComponent('dialogs.ChangelogDialog');
Modal.createTrackedDialog('Display Changelog', '', ChangelogDialog, {
version: this.props.version,
newVersion: this.props.newVersion,
onFinished: (update) => {
if(update && PlatformPeg.get()) {
PlatformPeg.get().installUpdate();
}
}
});
},
onUpdateClicked: function() {
PlatformPeg.get().installUpdate();
},
render: function() {
let action_button;
// If we have release notes to display, we display them. Otherwise,
// we display the Changelog Dialog which takes two versions and
// automatically tells you what's changed (provided the versions
// are in the right format)
if (this.props.releaseNotes) {
action_button = (
<button className="mx_MatrixToolbar_action" onClick={this.displayReleaseNotes}>
{ _t("What's new?") }
</button>
);
} else if (checkVersion(this.props.version) && checkVersion(this.props.newVersion)) {
action_button = (
<button className="mx_MatrixToolbar_action" onClick={this.displayChangelog}>
{ _t("What's new?") }
</button>
);
} else if (PlatformPeg.get()) {
action_button = (
<button className="mx_MatrixToolbar_action" onClick={this.onUpdateClicked}>
{ _t("Update") }
</button>
);
}
return (
<div className="mx_MatrixToolbar">
<img className="mx_MatrixToolbar_warning" src="img/warning.svg" width="24" height="23" alt="Warning"/>
<div className="mx_MatrixToolbar_content">
{_t("A new version of Riot is available.")}
</div>
{action_button}
</div>
);
}
});

View file

@ -0,0 +1,64 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import sdk from '../../../index';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
export default React.createClass({
onUpdateClicked: function() {
const SetPasswordDialog = sdk.getComponent('dialogs.SetPasswordDialog');
Modal.createTrackedDialog('Set Password Dialog', 'Password Nag Bar', SetPasswordDialog, {
onFinished: (passwordChanged) => {
if (!passwordChanged) {
return;
}
// Notify SessionStore that the user's password was changed
dis.dispatch({
action: 'password_changed',
});
},
});
},
render: function() {
const toolbarClasses = "mx_MatrixToolbar mx_MatrixToolbar_clickable";
return (
<div className={toolbarClasses} onClick={this.onUpdateClicked}>
<img className="mx_MatrixToolbar_warning"
src="img/warning.svg"
width="24"
height="23"
alt="Warning"
/>
<div className="mx_MatrixToolbar_content">
{ _t(
"To return to your account in future you need to <u>set a password</u>",
{},
{ 'u': (sub) => <u>{ sub }</u> },
) }
</div>
<button className="mx_MatrixToolbar_action">
{ _t("Set Password") }
</button>
</div>
);
},
});

View file

@ -0,0 +1,91 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import { _t } from '../../../languageHandler';
import PlatformPeg from '../../../PlatformPeg';
import AccessibleButton from '../../../components/views/elements/AccessibleButton';
export default React.createClass({
propTypes: {
status: React.PropTypes.string.isRequired,
// Currently for error detail but will be usable for download progress
// once that is a thing that squirrel passes through electron.
detail: React.PropTypes.string,
},
getDefaultProps: function() {
return {
detail: '',
}
},
getStatusText: function() {
// we can't import the enum from riot-web as we don't want matrix-react-sdk
// to depend on riot-web. so we grab it as a normal object via API instead.
const updateCheckStatusEnum = PlatformPeg.get().getUpdateCheckStatusEnum();
switch(this.props.status) {
case updateCheckStatusEnum.ERROR:
return _t('Error encountered (%(errorDetail)s).', { errorDetail: this.props.detail });
case updateCheckStatusEnum.CHECKING:
return _t('Checking for an update...');
case updateCheckStatusEnum.NOTAVAILABLE:
return _t('No update available.');
case updateCheckStatusEnum.DOWNLOADING:
return _t('Downloading update...');
}
},
hideToolbar: function() {
PlatformPeg.get().stopUpdateCheck();
},
render: function() {
const message = this.getStatusText();
const warning = _t('Warning');
if (!'getUpdateCheckStatusEnum' in PlatformPeg.get()) {
return <div></div>;
}
const updateCheckStatusEnum = PlatformPeg.get().getUpdateCheckStatusEnum();
const doneStatuses = [
updateCheckStatusEnum.ERROR,
updateCheckStatusEnum.NOTAVAILABLE,
];
let image;
if (doneStatuses.includes(this.props.status)) {
image = <img className="mx_MatrixToolbar_warning" src="img/warning.svg" width="24" height="23" alt={warning}/>;
} else {
image = <img className="mx_MatrixToolbar_warning" src="img/spinner.gif" width="24" height="23" alt={message}/>;
}
return (
<div className="mx_MatrixToolbar">
{image}
<div className="mx_MatrixToolbar_content">
{message}
</div>
<AccessibleButton className="mx_MatrixToolbar_close" onClick={this.hideToolbar}>
<img src="img/cancel.svg" width="18" height="18" />
</AccessibleButton>
</div>
);
}
});

View file

@ -23,9 +23,8 @@ import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GroupStore from '../../../stores/GroupStore';
import AccessibleButton from '../elements/AccessibleButton';
import GeminiScrollbar from 'react-gemini-scrollbar';
module.exports = React.createClass({
displayName: 'GroupMemberInfo',
@ -48,33 +47,37 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._unmounted = false;
this._initGroupStore(this.props.groupId);
},
componentWillReceiveProps(newProps) {
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore();
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
}
},
_initGroupStore(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
this._groupStore.registerListener(this.onGroupStoreUpdated);
componentWillUnmount() {
this._unmounted = true;
this._unregisterGroupStore(this.props.groupId);
},
_unregisterGroupStore() {
if (this._groupStore) {
this._groupStore.unregisterListener(this.onGroupStoreUpdated);
}
_initGroupStore(groupId) {
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
},
_unregisterGroupStore(groupId) {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
},
onGroupStoreUpdated: function() {
if (this._unmounted) return;
this.setState({
isUserInvited: this._groupStore.getGroupInvitedMembers().some(
isUserInvited: GroupStore.getGroupInvitedMembers(this.props.groupId).some(
(m) => m.userId === this.props.groupMember.userId,
),
isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
},
@ -132,7 +135,9 @@ module.exports = React.createClass({
render: function() {
if (this.state.removingUser) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
return <div className="mx_MemberInfo">
<Spinner />
</div>;
}
let adminTools;
@ -178,9 +183,10 @@ module.exports = React.createClass({
);
const EmojiText = sdk.getComponent('elements.EmojiText');
const GeminiScrollbarWrapper = sdk.getComponent('elements.GeminiScrollbarWrapper');
return (
<div className="mx_MemberInfo">
<GeminiScrollbar autoshow={true}>
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel"onClick={this._onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
@ -197,7 +203,7 @@ module.exports = React.createClass({
</div>
{ adminTools }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -17,8 +17,7 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GeminiScrollbar from 'react-gemini-scrollbar';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
const INITIAL_LOAD_NUM_MEMBERS = 30;
@ -43,9 +42,12 @@ export default React.createClass({
this._initGroupStore(this.props.groupId);
},
componentWillUnmount: function() {
this._unmounted = true;
},
_initGroupStore: function(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
GroupStore.registerListener(groupId, () => {
this._fetchMembers();
});
},
@ -53,8 +55,8 @@ export default React.createClass({
_fetchMembers: function() {
if (this._unmounted) return;
this.setState({
members: this._groupStore.getGroupMembers(),
invitedMembers: this._groupStore.getGroupInvitedMembers(),
members: GroupStore.getGroupMembers(this.props.groupId),
invitedMembers: GroupStore.getGroupInvitedMembers(this.props.groupId),
});
},
@ -134,6 +136,7 @@ export default React.createClass({
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.fetching || this.state.fetchingInvitedMembers) {
const Spinner = sdk.getComponent("elements.Spinner");
return (<div className="mx_MemberList">
@ -162,10 +165,10 @@ export default React.createClass({
return (
<div className="mx_MemberList">
{ inputBox }
<GeminiScrollbar autoshow={true} className="mx_MemberList_outerWrapper">
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_outerWrapper">
{ joined }
{ invited }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GroupStore from '../../../stores/GroupStore';
import { _t } from '../../../languageHandler.js';
@ -41,15 +40,18 @@ export default React.createClass({
},
_initGroupStore: function(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
this._groupStoreToken = GroupStore.registerListener(groupId, () => {
this.setState({
isGroupPublicised: this._groupStore.getGroupPublicity(),
ready: this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary),
isGroupPublicised: GroupStore.getGroupPublicity(groupId),
ready: GroupStore.isStateReady(groupId, GroupStore.STATE_KEY.Summary),
});
});
},
componentWillUnmount() {
if (this._groupStoreToken) this._groupStoreToken.unregister();
},
_onPublicityToggle: function(e) {
e.stopPropagation();
this.setState({
@ -57,7 +59,7 @@ export default React.createClass({
// Optimistic early update
isGroupPublicised: !this.state.isGroupPublicised,
});
this._groupStore.setGroupPublicity(!this.state.isGroupPublicised).then(() => {
GroupStore.setGroupPublicity(this.props.groupId, !this.state.isGroupPublicised).then(() => {
this.setState({
busy: false,
});

View file

@ -21,8 +21,7 @@ import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GeminiScrollbar from 'react-gemini-scrollbar';
import GroupStore from '../../../stores/GroupStore';
module.exports = React.createClass({
displayName: 'GroupRoomInfo',
@ -51,29 +50,26 @@ module.exports = React.createClass({
componentWillReceiveProps(newProps) {
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore();
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
}
},
componentWillUnmount() {
this._unregisterGroupStore();
this._unregisterGroupStore(this.props.groupId);
},
_initGroupStore(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
this._groupStore.registerListener(this.onGroupStoreUpdated);
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
},
_unregisterGroupStore() {
if (this._groupStore) {
this._groupStore.unregisterListener(this.onGroupStoreUpdated);
}
_unregisterGroupStore(groupId) {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
},
_updateGroupRoom() {
this.setState({
groupRoom: this._groupStore.getGroupRooms().find(
groupRoom: GroupStore.getGroupRooms(this.props.groupId).find(
(r) => r.roomId === this.props.groupRoomId,
),
});
@ -81,7 +77,7 @@ module.exports = React.createClass({
onGroupStoreUpdated: function() {
this.setState({
isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
this._updateGroupRoom();
},
@ -101,7 +97,7 @@ module.exports = React.createClass({
this.setState({groupRoomRemoveLoading: true});
const groupId = this.props.groupId;
const roomId = this.props.groupRoomId;
this._groupStore.removeRoomFromGroup(roomId).then(() => {
GroupStore.removeRoomFromGroup(this.props.groupId, roomId).then(() => {
dis.dispatch({
action: "view_group_room_list",
});
@ -135,7 +131,7 @@ module.exports = React.createClass({
const groupId = this.props.groupId;
const roomId = this.props.groupRoomId;
const roomName = this.state.groupRoom.displayname;
this._groupStore.updateGroupRoomVisibility(roomId, isPublic).catch((err) => {
GroupStore.updateGroupRoomVisibility(this.props.groupId, roomId, isPublic).catch((err) => {
console.error(`Error whilst changing visibility of ${roomId} in ${groupId} to ${isPublic}`, err);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, {
@ -157,6 +153,7 @@ module.exports = React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.groupRoomRemoveLoading || !this.state.groupRoom) {
const Spinner = sdk.getComponent("elements.Spinner");
return <div className="mx_MemberInfo">
@ -216,7 +213,7 @@ module.exports = React.createClass({
const avatar = <BaseAvatar name={groupRoomName} width={36} height={36} url={avatarUrl} />;
return (
<div className="mx_MemberInfo">
<GeminiScrollbar autoshow={true}>
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this._onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
@ -233,7 +230,7 @@ module.exports = React.createClass({
</div>
{ adminTools }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -16,8 +16,7 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GeminiScrollbar from 'react-gemini-scrollbar';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
const INITIAL_LOAD_NUM_ROOMS = 30;
@ -40,22 +39,31 @@ export default React.createClass({
this._initGroupStore(this.props.groupId);
},
componentWillUnmount() {
this._unmounted = true;
this._unregisterGroupStore();
},
_unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
},
_initGroupStore: function(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
this._fetchRooms();
});
this._groupStore.on('error', (err) => {
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
// XXX: This should be more fluxy - let's get the error from GroupStore .getError or something
// XXX: This is also leaked - we should remove it when unmounting
GroupStore.on('error', (err, errorGroupId) => {
if (errorGroupId !== groupId) return;
this.setState({
rooms: null,
});
});
},
_fetchRooms: function() {
onGroupStoreUpdated: function() {
if (this._unmounted) return;
this.setState({
rooms: this._groupStore.getGroupRooms(),
rooms: GroupStore.getGroupRooms(this.props.groupId),
});
},
@ -120,16 +128,17 @@ export default React.createClass({
</form>
);
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return (
<div className="mx_GroupRoomList">
{ inputBox }
<GeminiScrollbar autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<GeminiScrollbarWrapper autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -17,10 +17,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClient} from 'matrix-js-sdk';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore';
const GroupTile = React.createClass({
displayName: 'GroupTile',
@ -57,7 +59,7 @@ const GroupTile = React.createClass({
});
},
onClick: function(e) {
onMouseDown: function(e) {
e.preventDefault();
dis.dispatch({
action: 'view_group',
@ -77,10 +79,52 @@ const GroupTile = React.createClass({
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
return <AccessibleButton className="mx_GroupTile" onClick={this.onClick}>
<div className="mx_GroupTile_avatar">
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
</div>
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown}>
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
idName={this.props.groupId}
url={httpUrl}
width={avatarHeight}
height={avatarHeight} />
</div>
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ?
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
idName={this.props.groupId}
url={httpUrl}
width={avatarHeight}
height={avatarHeight} />
</div> :
<div />
}
</div>
) }
</Draggable>
</div>
) }
</Droppable>
<div className="mx_GroupTile_profile">
<div className="mx_GroupTile_name">{ name }</div>
{ descElement }

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import GeminiScrollbar from 'react-gemini-scrollbar';
import sdk from '../../../index';
import { MatrixClient } from 'matrix-js-sdk';
import { _t } from '../../../languageHandler';
@ -55,14 +54,15 @@ export default React.createClass({
text = _t('Loading...');
} else if (groups.length > 0) {
const GroupPublicityToggle = sdk.getComponent('groups.GroupPublicityToggle');
const GeminiScrollbarWrapper = sdk.getComponent('elements.GeminiScrollbarWrapper');
const groupPublicityToggles = groups.map((groupId, index) => {
return <GroupPublicityToggle key={index} groupId={groupId} />;
});
text = _t('Display your community flair in rooms configured to show it.');
scrollbox = <div className="mx_GroupUserSettings_groupPublicity_scrollbox">
<GeminiScrollbar>
<GeminiScrollbarWrapper>
{ groupPublicityToggles }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>;
} else {
text = _t("You're not currently a member of any communities.");

View file

@ -70,7 +70,7 @@ export default class CountryDropdown extends React.Component {
}
_flagImgForIso2(iso2) {
return <img src={`flags/${iso2}.png`} />;
return <img src={`img/flags/${iso2}.png`} />;
}
_getShortOption(iso2) {

View file

@ -128,12 +128,22 @@ export const PasswordAuthEntry = React.createClass({
);
}
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<p>{ _t("To continue, please enter your password.") }</p>
<p>{ _t("Password:") }</p>
<form onSubmit={this._onSubmit}>
<label htmlFor="passwordField">{ _t("Password:") }</label>
<input
name="passwordField"
ref="passwordField"
className={passwordBoxClass}
onChange={this._onPasswordFieldChange}
@ -143,9 +153,7 @@ export const PasswordAuthEntry = React.createClass({
{ submitButtonOrSpinner }
</div>
</form>
<div className="error">
{ this.props.errorText }
</div>
{ errorSection }
</div>
);
},
@ -180,14 +188,22 @@ export const RecaptchaAuthEntry = React.createClass({
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
const sitePublicKey = this.props.stageParams.public_key;
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
/>
<div className="error">
{ this.props.errorText }
</div>
{ errorSection }
</div>
);
},
@ -372,6 +388,14 @@ export const MsisdnAuthEntry = React.createClass({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
});
let errorSection;
if (this.state.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.state.errorText }
</div>
);
}
return (
<div>
<p>{ _t("A text message has been sent to %(msisdn)s",
@ -385,6 +409,7 @@ export const MsisdnAuthEntry = React.createClass({
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
aria-label={ _t("Code")}
/>
<br />
<input type="submit" value={_t("Submit")}
@ -392,9 +417,7 @@ export const MsisdnAuthEntry = React.createClass({
disabled={!enableSubmit}
/>
</form>
<div className="error">
{ this.state.errorText }
</div>
{errorSection}
</div>
</div>
);
@ -427,6 +450,12 @@ export const FallbackAuthEntry = React.createClass({
}
},
focus: function() {
if (this.refs.fallbackButton) {
this.refs.fallbackButton.focus();
}
},
_onShowFallbackClick: function() {
const url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
@ -445,12 +474,18 @@ export const FallbackAuthEntry = React.createClass({
},
render: function() {
return (
<div>
<a onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
<div className="error">
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<a ref="fallbackButton" onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
{errorSection}
</div>
);
},

View file

@ -0,0 +1,61 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {formatFullDateNoTime} from '../../../DateUtils';
function getdaysArray() {
return [
_t('Sunday'),
_t('Monday'),
_t('Tuesday'),
_t('Wednesday'),
_t('Thursday'),
_t('Friday'),
_t('Saturday'),
];
}
export default class DateSeparator extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,
};
getLabel() {
const date = new Date(this.props.ts);
const today = new Date();
const yesterday = new Date();
const days = getdaysArray();
yesterday.setDate(today.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return _t('Today');
} else if (date.toDateString() === yesterday.toDateString()) {
return _t('Yesterday');
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
return days[date.getDay()];
} else {
return formatFullDateNoTime(date);
}
}
render() {
return <h2 className="mx_DateSeparator">{ this.getLabel() }</h2>;
}
}

View file

@ -20,7 +20,7 @@ import React from 'react';
import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
export default class MAudioBody extends React.Component {
@ -54,7 +54,7 @@ export default class MAudioBody extends React.Component {
let decryptedBlob;
decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(decryptedBlob);
return URL.createObjectURL(decryptedBlob);
}).done((url) => {
this.setState({
decryptedUrl: url,
@ -69,6 +69,12 @@ export default class MAudioBody extends React.Component {
}
}
componentWillUnmount() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
}
render() {
const content = this.props.mxEvent.getContent();

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -82,7 +83,7 @@ Tinter.registerTintable(updateTintedDownloadImage);
// downloaded. This limit does not seem to apply when the url is used as
// the source attribute of an image tag.
//
// Blob URLs are generated using window.URL.createObjectURL and unforuntately
// Blob URLs are generated using window.URL.createObjectURL and unfortunately
// for our purposes they inherit the origin of the page that created them.
// This means that any scripts that run when the URL is viewed will be able
// to access local storage.
@ -99,16 +100,27 @@ Tinter.registerTintable(updateTintedDownloadImage);
// overridable so that people running their own version of the client can
// choose a different renderer.
//
// To that end the first version of the blob generation will be the following
// To that end the current version of the blob generation is the following
// html:
//
// <html><head><script>
// window.onmessage=function(e){eval("("+e.data.code+")")(e)}
// var params = window.location.search.substring(1).split('&');
// var lockOrigin;
// for (var i = 0; i < params.length; ++i) {
// var parts = params[i].split('=');
// if (parts[0] == 'origin') lockOrigin = decodeURIComponent(parts[1]);
// }
// window.onmessage=function(e){
// if (lockOrigin === undefined || e.origin === lockOrigin) eval("("+e.data.code+")")(e);
// }
// </script></head><body></body></html>
//
// This waits to receive a message event sent using the window.postMessage API.
// When it receives the event it evals a javascript function in data.code and
// runs the function passing the event as an argument.
// runs the function passing the event as an argument. This version adds
// support for a query parameter controlling the origin from which messages
// will be processed as an extra layer of security (note that the default URL
// is still 'v1' since it is backwards compatible).
//
// In particular it means that the rendering function can be written as a
// ordinary javascript function which then is turned into a string using
@ -295,7 +307,7 @@ module.exports = React.createClass({
return (
<span className="mx_MFileBody" ref="body">
<div className="mx_MImageBody_download">
<div className="mx_MFileBody_download">
<a href="javascript:void(0)" onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
</a>
@ -325,9 +337,10 @@ module.exports = React.createClass({
if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) {
renderer_url = this.context.appConfig.cross_origin_renderer_url;
}
renderer_url += "?origin=" + encodeURIComponent(window.location.origin);
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<div className="mx_MFileBody_download">
<div style={{display: "none"}}>
{ /*
* Add dummy copy of the "a" tag
@ -347,8 +360,8 @@ module.exports = React.createClass({
if (this.props.tileShape === "file_grid") {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
<div className="mx_MFileBody_download">
<a className="mx_MFileBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
{ fileName }
</a>
<div className="mx_MImageBody_size">
@ -360,7 +373,7 @@ module.exports = React.createClass({
} else {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<div className="mx_MFileBody_download">
<a href={contentUrl} download={fileName} target="_blank" rel="noopener">
<img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage" />
{ _t("Download %(text)s", { text: text }) }

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,39 +19,75 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
import ImageUtils from '../../../ImageUtils';
import Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import { decryptFile } from '../../../utils/DecryptFile';
import Promise from 'bluebird';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
displayName: 'MImageBody',
export default class extends React.Component {
displayName: 'MImageBody'
propTypes: {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* called when the image has loaded */
onWidgetLoad: PropTypes.func.isRequired,
},
}
getInitialState: function() {
return {
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
}
constructor(props) {
super(props);
this.onAction = this.onAction.bind(this);
this.onImageError = this.onImageError.bind(this);
this.onImageLoad = this.onImageLoad.bind(this);
this.onImageEnter = this.onImageEnter.bind(this);
this.onImageLeave = this.onImageLeave.bind(this);
this.onClientSync = this.onClientSync.bind(this);
this.onClick = this.onClick.bind(this);
this.fixupHeight = this.fixupHeight.bind(this);
this._isGif = this._isGif.bind(this);
this.state = {
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
imgError: false,
};
},
}
componentWillMount() {
this.unmounted = false;
this.context.matrixClient.on('sync', this.onClientSync);
}
onClick: function onClick(ev) {
// FIXME: factor this out and aplpy it to MVideoBody and MAudioBody too!
onClientSync(syncState, prevState) {
if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected && this.state.imgError) {
// Load the image again
this.setState({
imgError: false,
});
}
}
onClick(ev) {
if (ev.button == 0 && !ev.metaKey) {
ev.preventDefault();
const content = this.props.mxEvent.getContent();
@ -70,43 +107,53 @@ module.exports = React.createClass({
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}
},
}
_isGif: function() {
_isGif() {
const content = this.props.mxEvent.getContent();
return (
content &&
content.info &&
content.info.mimetype === "image/gif"
);
},
}
onImageEnter: function(e) {
onImageEnter(e) {
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return;
}
const imgElement = e.target;
imgElement.src = this._getContentUrl();
},
}
onImageLeave: function(e) {
onImageLeave(e) {
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return;
}
const imgElement = e.target;
imgElement.src = this._getThumbUrl();
},
}
_getContentUrl: function() {
onImageError() {
this.setState({
imgError: true,
});
}
onImageLoad() {
this.props.onWidgetLoad();
}
_getContentUrl() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
return this.context.matrixClient.mxcUrlToHttp(content.url);
}
},
}
_getThumbUrl: function() {
_getThumbUrl() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
// Don't use the thumbnail for clients wishing to autoplay gifs.
@ -114,14 +161,22 @@ module.exports = React.createClass({
return this.state.decryptedThumbnailUrl;
}
return this.state.decryptedUrl;
} else if (content.info &&
content.info.mimetype == "image/svg+xml" &&
content.info.thumbnail_url) {
// special case to return client-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar
return this.context.matrixClient.mxcUrlToHttp(
content.info.thumbnail_url, 800, 600,
);
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600);
return this.context.matrixClient.mxcUrlToHttp(content.url, 800, 600);
}
},
}
componentDidMount: function() {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.fixupHeight();
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
@ -129,21 +184,20 @@ module.exports = React.createClass({
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
return readBlobAsDataUri(blob);
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
return URL.createObjectURL(blob);
}).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onWidgetLoad();
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err);
@ -153,21 +207,43 @@ module.exports = React.createClass({
});
}).done();
}
},
this.fixupHeight();
this._afterComponentDidMount();
}
componentWillUnmount: function() {
// To be overridden by subclasses (e.g. MStickerBody) for further
// initialisation after componentDidMount
_afterComponentDidMount() {
}
componentWillUnmount() {
this.unmounted = true;
dis.unregister(this.dispatcherRef);
},
this.context.matrixClient.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount();
onAction: function(payload) {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
}
// To be overridden by subclasses (e.g. MStickerBody) for further
// cleanup after componentWillUnmount
_afterComponentWillUnmount() {
}
onAction(payload) {
if (payload.action === "timeline_resize") {
this.fixupHeight();
}
},
}
fixupHeight: function() {
fixupHeight() {
if (!this.refs.image) {
console.warn("Refusing to fix up height on MImageBody with no image element");
console.warn(`Refusing to fix up height on ${this.displayName} with no image element`);
return;
}
@ -176,17 +252,45 @@ module.exports = React.createClass({
const maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px.
// the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box
//console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
// FIXME: this will break on clientside generated thumbnails (as per e2e rooms)
// which may well be much smaller than the 800x600 bounding box.
// FIXME: It will also break really badly for images with broken or missing thumbnails
// FIXME: Because we don't know what size of thumbnail the server's actually going to send
// us, we can't even really layout the page nicely for it. Instead we have to assume
// it'll target 800x600 and we'll downsize if needed to make things fit.
// console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
let thumbHeight = null;
if (content.info) {
thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
}
this.refs.image.style.height = thumbHeight + "px";
// console.log("Image height now", thumbHeight);
},
}
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
_messageContent(contentUrl, thumbUrl, content) {
const thumbnail = (
<a href={contentUrl} onClick={this.onClick}>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
);
return (
<span className="mx_MImageBody" ref="body">
{ thumbUrl && !this.state.imgError ? thumbnail : '' }
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
}
render() {
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
@ -225,31 +329,6 @@ module.exports = React.createClass({
thumbUrl = this._getThumbUrl();
}
if (thumbUrl) {
return (
<span className="mx_MImageBody" ref="body">
<a href={contentUrl} onClick={this.onClick}>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onLoad={this.props.onWidgetLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
} else if (content.body) {
return (
<span className="mx_MImageBody">
{ _t("Image '%(Body)s' cannot be displayed.", {Body: content.body}) }
</span>
);
} else {
return (
<span className="mx_MImageBody">
{ _t("This image cannot be displayed.") }
</span>
);
}
},
});
return this._messageContent(contentUrl, thumbUrl, content);
}
}

View file

@ -0,0 +1,160 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import MImageBody from './MImageBody';
import sdk from '../../../index';
import TintableSVG from '../elements/TintableSvg';
export default class MStickerBody extends MImageBody {
displayName: 'MStickerBody'
constructor(props) {
super(props);
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);
this._onImageLoad = this._onImageLoad.bind(this);
}
_onMouseEnter() {
this.setState({showTooltip: true});
}
_onMouseLeave() {
this.setState({showTooltip: false});
}
_onImageLoad() {
this.setState({
placeholderClasses: 'mx_MStickerBody_placeholder_invisible',
});
const hidePlaceholderTimer = setTimeout(() => {
this.setState({
placeholderVisible: false,
imageClasses: 'mx_MStickerBody_image_visible',
});
}, 500);
this.setState({hidePlaceholderTimer});
if (this.props.onWidgetLoad) {
this.props.onWidgetLoad();
}
}
_afterComponentDidMount() {
if (this.refs.image.complete) {
// Image already loaded
this.setState({
placeholderVisible: false,
placeholderClasses: '.mx_MStickerBody_placeholder_invisible',
imageClasses: 'mx_MStickerBody_image_visible',
});
} else {
// Image not already loaded
this.setState({
placeholderVisible: true,
placeholderClasses: '',
imageClasses: '',
});
}
}
_afterComponentWillUnmount() {
if (this.state.hidePlaceholderTimer) {
clearTimeout(this.state.hidePlaceholderTimer);
this.setState({hidePlaceholderTimer: null});
}
}
_messageContent(contentUrl, thumbUrl, content) {
let tooltip;
const tooltipBody = (
this.props.mxEvent &&
this.props.mxEvent.getContent() &&
this.props.mxEvent.getContent().body) ?
this.props.mxEvent.getContent().body : null;
if (this.state.showTooltip && tooltipBody) {
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
tooltip = <RoomTooltip
className='mx_RoleButton_tooltip'
label={tooltipBody} />;
}
const gutterSize = 0;
let placeholderSize = 75;
let placeholderFixupHeight = '100px';
let placeholderTop = 0;
let placeholderLeft = 0;
if (content.info) {
placeholderTop = Math.floor((content.info.h/2) - (placeholderSize/2)) + 'px';
placeholderLeft = Math.floor((content.info.w/2) - (placeholderSize/2) + gutterSize) + 'px';
placeholderFixupHeight = content.info.h + 'px';
}
// The pixel size of sticker images is generally larger than their intended display
// size so they render at native reolution on HiDPI displays. We therefore need to
// explicity set the size so they render at the intended size.
// XXX: This will be clobberred when we run fixupHeight(), but we need to do it
// here otherwise the stickers are momentarily displayed at the pixel size.
const imageStyle = {
height: content.info.h,
// leave the browser the calculate the width automatically
};
placeholderSize = placeholderSize + 'px';
// Body 'ref' required by MImageBody
return (
<span className='mx_MStickerBody' ref='body'
style={{
height: placeholderFixupHeight,
}}>
<div className={'mx_MStickerBody_image_container'}>
{ this.state.placeholderVisible &&
<div
className={'mx_MStickerBody_placeholder ' + this.state.placeholderClasses}
style={{
top: placeholderTop,
left: placeholderLeft,
}}
>
<TintableSVG
src={'img/icons-show-stickers.svg'}
width={placeholderSize}
height={placeholderSize} />
</div> }
<img
className={'mx_MStickerBody_image ' + this.state.imageClasses}
src={contentUrl}
style={imageStyle}
ref='image'
alt={content.body}
onLoad={this._onImageLoad}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
/>
{ tooltip }
</div>
</span>
);
}
// Empty to prevent default behaviour of MImageBody
onClick() {
}
}

View file

@ -20,7 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import { decryptFile } from '../../../utils/DecryptFile';
import Promise from 'bluebird';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
@ -94,14 +94,14 @@ module.exports = React.createClass({
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
return readBlobAsDataUri(blob);
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
return URL.createObjectURL(blob);
}).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
@ -120,6 +120,15 @@ module.exports = React.createClass({
}
},
componentWillUnmount: function() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
},
render: function() {
const content = this.props.mxEvent.getContent();

View file

@ -65,15 +65,19 @@ module.exports = React.createClass({
let BodyType = UnknownBody;
if (msgtype && bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype];
} else if (this.props.mxEvent.getType() === 'm.sticker') {
BodyType = sdk.getComponent('messages.MStickerBody');
} else if (content.url) {
// Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file'];
}
return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />;
return <BodyType
ref="body" mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />;
},
});

View file

@ -0,0 +1,36 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {formatFullDate, formatTime} from '../../../DateUtils';
export default class MessageTimestamp extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,
showTwelveHour: PropTypes.bool,
};
render() {
const date = new Date(this.props.ts);
return (
<span className="mx_MessageTimestamp" title={formatFullDate(date, this.props.showTwelveHour)}>
{ formatTime(date, this.props.showTwelveHour) }
</span>
);
}
}

View file

@ -32,9 +32,10 @@ import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import ContextualMenu from '../../structures/ContextualMenu';
import * as ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread";
linkifyMatrix(linkify);
@ -182,7 +183,6 @@ module.exports = React.createClass({
// If the link is a (localised) matrix.to link, replace it with a pill
const Pill = sdk.getComponent('elements.Pill');
const Quote = sdk.getComponent('elements.Quote');
if (Pill.isMessagePillUrl(href)) {
const pillContainer = document.createElement('span');
@ -201,19 +201,6 @@ module.exports = React.createClass({
// update the current node with one that's now taken its place
node = pillContainer;
} else if (SettingsStore.isFeatureEnabled("feature_rich_quoting") && Quote.isMessageUrl(href)) {
// only allow this branch if we're not already in a quote, as fun as infinite nesting is.
const quoteContainer = document.createElement('span');
const quote =
<Quote url={href} parentEv={this.props.mxEvent} isNested={this.props.tileShape === 'quote'} />;
ReactDOM.render(quote, quoteContainer);
node.parentNode.replaceChild(quoteContainer, node);
pillified = true;
node = quoteContainer;
}
} else if (node.nodeType == Node.TEXT_NODE) {
const Pill = sdk.getComponent('elements.Pill');
@ -331,7 +318,7 @@ module.exports = React.createClass({
_addCodeCopyButton() {
// Add 'copy' buttons to pre blocks
ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre').forEach((p) => {
Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
const button = document.createElement("span");
button.className = "mx_EventTile_copyButton";
button.onclick = (e) => {
@ -435,8 +422,12 @@ module.exports = React.createClass({
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const stripReply = SettingsStore.isFeatureEnabled("feature_rich_quoting") &&
ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'),
// Part of Replies fallback support
stripReplyFallback: stripReply,
});
if (this.props.highlightLink) {

View file

@ -58,8 +58,8 @@ module.exports = React.createClass({
state.domainToAliases = this.aliasEventsToDictionary(aliasEvents);
state.remoteDomains = Object.keys(state.domainToAliases).filter((alias) => {
return alias !== localDomain;
state.remoteDomains = Object.keys(state.domainToAliases).filter((domain) => {
return domain !== localDomain && state.domainToAliases[domain].length > 0;
});
if (canonicalAliasEvent) {

View file

@ -37,7 +37,15 @@ module.exports = React.createClass({
displayName: 'AppsDrawer',
propTypes: {
userId: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
showApps: PropTypes.bool, // Should apps be rendered
hide: PropTypes.bool, // If rendered, should apps drawer be visible
},
defaultProps: {
showApps: true,
hide: false,
},
getInitialState: function() {
@ -48,7 +56,7 @@ module.exports = React.createClass({
componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents);
},
componentDidMount: function() {
@ -58,7 +66,7 @@ module.exports = React.createClass({
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
console.log("Failed to connect to integrations server");
console.log('Failed to connect to integrations server');
// TODO -- Handle Scalar errors
// this.setState({
// scalar_error: err,
@ -72,7 +80,7 @@ module.exports = React.createClass({
componentWillUnmount: function() {
ScalarMessaging.stopListening();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
}
dis.unregister(this.dispatcherRef);
},
@ -83,7 +91,7 @@ module.exports = React.createClass({
},
onAction: function(action) {
const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer";
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
case 'appsDrawer':
// When opening the app drawer when there aren't any apps,
@ -111,7 +119,7 @@ module.exports = React.createClass({
* passed through encodeURIComponent.
* @param {string} pathTemplate 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" }.
* variables with. E.g. { '$bar': 'baz' }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
@ -192,13 +200,13 @@ module.exports = React.createClass({
},
_launchManageIntegrations: function() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') :
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
}, 'mx_IntegrationsManager');
},
onClickAddWidget: function(e) {
@ -206,12 +214,12 @@ module.exports = React.createClass({
// Display a warning dialog if the max number of widgets have already been added to the room
const apps = this._getApps();
if (apps && apps.length >= MAX_WIDGETS) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
const errorMsg = `The maximum number of ${MAX_WIDGETS} widgets have already been added to this room.`;
console.error(errorMsg);
Modal.createDialog(ErrorDialog, {
title: _t("Cannot add any more widgets"),
description: _t("The maximum permitted number of widgets have already been added to this room."),
title: _t('Cannot add any more widgets'),
description: _t('The maximum permitted number of widgets have already been added to this room.'),
});
return;
}
@ -219,6 +227,8 @@ module.exports = React.createClass({
},
render: function() {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", this.props.room.room_id);
const apps = this.state.apps.map(
(app, index, arr) => {
return (<AppTile
@ -234,6 +244,7 @@ module.exports = React.createClass({
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={enableScreenshots ? ["m.capability.screenshot"] : []}
/>);
});
@ -243,11 +254,11 @@ module.exports = React.createClass({
) {
addWidget = <div
onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
role='button'
tabIndex='0'
className={this.state.apps.length<2 ?
"mx_AddWidget_button mx_AddWidget_button_full_width" :
"mx_AddWidget_button"
'mx_AddWidget_button mx_AddWidget_button_full_width' :
'mx_AddWidget_button'
}
title={_t('Add a widget')}>
[+] { _t('Add a widget') }
@ -255,8 +266,8 @@ module.exports = React.createClass({
}
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
<div className={'mx_AppsDrawer' + (this.props.hide ? ' mx_AppsDrawer_hidden' : '')}>
<div id='apps' className='mx_AppsContainer'>
{ apps }
</div>
{ this._canUserModify() && addWidget }

View file

@ -32,7 +32,8 @@ module.exports = React.createClass({
// js-sdk room object
room: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
showApps: PropTypes.bool,
showApps: PropTypes.bool, // Render apps
hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered)
// Conference Handler implementation
conferenceHandler: PropTypes.object,
@ -52,6 +53,11 @@ module.exports = React.createClass({
onResize: PropTypes.func,
},
defaultProps: {
showApps: true,
hideAppsDrawer: false,
},
shouldComponentUpdate: function(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
@ -134,6 +140,7 @@ module.exports = React.createClass({
userId={this.props.userId}
maxHeight={this.props.maxHeight}
showApps={this.props.showApps}
hide={this.props.hideAppsDrawer}
/>;
return (

View file

@ -0,0 +1,65 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import RoomTile from '../../../components/views/rooms/RoomTile';
import classNames from 'classnames';
export default class DNDRoomTile extends React.PureComponent {
constructor() {
super();
this.getClassName = this.getClassName.bind(this);
}
getClassName(isDragging) {
return classNames({
"mx_DNDRoomTile": true,
"mx_DNDRoomTile_dragging": isDragging,
});
}
render() {
const props = this.props;
return <div>
<Draggable
key={props.room.roomId}
draggableId={props.tagName + '_' + props.room.roomId}
index={props.index}
type="draggable-RoomTile"
>
{ (provided, snapshot) => {
return (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={this.getClassName(snapshot.isDragging)}>
<RoomTile {...props} />
</div>
</div>
{ provided.placeholder }
</div>
);
} }
</Draggable>
</div>;
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,7 +33,11 @@ const PRESENCE_CLASS = {
};
function presenceClassForMember(presenceState, lastActiveAgo) {
function presenceClassForMember(presenceState, lastActiveAgo, showPresence) {
if (showPresence === false) {
return 'mx_EntityTile_online_beenactive';
}
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState == 'offline') {
@ -64,6 +69,7 @@ const EntityTile = React.createClass({
shouldComponentUpdate: PropTypes.func,
onClick: PropTypes.func,
suppressOnHover: PropTypes.bool,
showPresence: PropTypes.bool,
},
getDefaultProps: function() {
@ -75,6 +81,7 @@ const EntityTile = React.createClass({
presenceLastTs: 0,
showInviteButton: false,
suppressOnHover: false,
showPresence: true,
};
},
@ -99,7 +106,7 @@ const EntityTile = React.createClass({
render: function() {
const presenceClass = presenceClassForMember(
this.props.presenceState, this.props.presenceLastActiveAgo,
this.props.presenceState, this.props.presenceLastActiveAgo, this.props.showPresence,
);
let mainClassName = "mx_EntityTile ";
@ -114,15 +121,21 @@ const EntityTile = React.createClass({
mainClassName += " mx_EntityTile_hover";
const PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
let presenceLabel = null;
let nameClasses = 'mx_EntityTile_name';
if (this.props.showPresence) {
presenceLabel = <PresenceLabel activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState} />;
nameClasses += ' mx_EntityTile_name_hover';
}
nameEl = (
<div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12" />
<EmojiText element="div" className="mx_EntityTile_name mx_EntityTile_name_hover" dir="auto">
<EmojiText element="div" className={nameClasses} dir="auto">
{ name }
</EmojiText>
<PresenceLabel activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState} />
{presenceLabel}
</div>
);
} else {

View file

@ -18,6 +18,8 @@ limitations under the License.
'use strict';
import ReplyThread from "../elements/ReplyThread";
const React = require('react');
import PropTypes from 'prop-types';
const classNames = require("classnames");
@ -31,11 +33,13 @@ import withMatrixClient from '../../../wrappers/withMatrixClient';
const ContextualMenu = require('../../structures/ContextualMenu');
import dis from '../../../dispatcher';
import {makeEventPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
const ObjectUtils = require('../../../ObjectUtils');
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
'm.sticker': 'messages.MessageEvent',
'm.call.invite': 'messages.TextualEvent',
'm.call.answer': 'messages.TextualEvent',
'm.call.hangup': 'messages.TextualEvent',
@ -151,8 +155,25 @@ module.exports = withMatrixClient(React.createClass({
isTwelveHour: PropTypes.bool,
},
getDefaultProps: function() {
return {
// no-op function because onWidgetLoad is optional yet some sub-components assume its existence
onWidgetLoad: function() {},
};
},
getInitialState: function() {
return {menu: false, allReadAvatars: false, verified: null};
return {
// Whether the context menu is being displayed.
menu: false,
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: false,
// Whether the event's sender has been verified.
verified: null,
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: false,
};
},
componentWillMount: function() {
@ -181,17 +202,12 @@ module.exports = withMatrixClient(React.createClass({
return true;
}
if (!this._propsEqual(this.props, nextProps)) {
return true;
}
return false;
return !this._propsEqual(this.props, nextProps);
},
componentWillUnmount: function() {
const client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged",
this.onDeviceVerificationChanged);
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
},
@ -206,7 +222,7 @@ module.exports = withMatrixClient(React.createClass({
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.mxEvent.getSender()) {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
@ -241,7 +257,7 @@ module.exports = withMatrixClient(React.createClass({
}
// need to deep-compare readReceipts
if (key == 'readReceipts') {
if (key === 'readReceipts') {
const rA = objA[key];
const rB = objB[key];
if (rA === rB) {
@ -289,12 +305,16 @@ module.exports = withMatrixClient(React.createClass({
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const self = this;
const {tile, replyThread} = this.refs;
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
eventTileOps: this.refs.tile && this.refs.tile.getEventTileOps ? this.refs.tile.getEventTileOps() : undefined,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
onFinished: function() {
self.setState({menu: false});
},
@ -311,7 +331,7 @@ module.exports = withMatrixClient(React.createClass({
getReadAvatars: function() {
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars"></span>);
return (<span className="mx_EventTile_readAvatars" />);
}
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
@ -335,7 +355,7 @@ module.exports = withMatrixClient(React.createClass({
left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
const userId = receipt.roomMember.userId;
var readReceiptInfo;
let readReceiptInfo;
if (this.props.readReceiptMap) {
readReceiptInfo = this.props.readReceiptMap[userId];
@ -393,6 +413,19 @@ module.exports = withMatrixClient(React.createClass({
});
},
onRequestKeysClick: function() {
this.setState({
// Indicate in the UI that the keys have been requested (this is expected to
// be reset if the component is mounted in the future).
previouslyRequestedKeys: true,
});
// Cancel any outgoing key request for this event and resend it. If a response
// is received for the request with the required keys, the event could be
// decrypted successfully.
this.props.matrixClient.cancelAndResendEventRoomKeyRequest(this.props.mxEvent);
},
onPermalinkClicked: function(e) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
@ -447,7 +480,7 @@ module.exports = withMatrixClient(React.createClass({
const eventType = this.props.mxEvent.getType();
// Info messages are basically information about commands processed on a room
const isInfoMessage = (eventType !== 'm.room.message');
const isInfoMessage = (eventType !== 'm.room.message' && eventType !== 'm.sticker');
const EventTileType = sdk.getComponent(getHandlerTile(this.props.mxEvent));
// This shouldn't happen: the caller should check we support this type
@ -458,23 +491,24 @@ module.exports = withMatrixClient(React.createClass({
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const classes = classNames({
mx_EventTile: true,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus == 'encrypting',
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
mx_EventTile_sending: isSending,
mx_EventTile_notSent: this.props.eventSendStatus == 'not_sent',
mx_EventTile_highlight: this.props.tileShape == 'notif' ? false : this.shouldHighlight(),
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false,
mx_EventTile_bad: msgtype === 'm.bad.encrypted',
mx_EventTile_verified: this.state.verified === true,
mx_EventTile_unverified: this.state.verified === false,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
});
@ -483,7 +517,8 @@ module.exports = withMatrixClient(React.createClass({
const readAvatars = this.getReadAvatars();
let avatar, sender;
let avatar;
let sender;
let avatarSize;
let needsSenderProfile;
@ -517,11 +552,14 @@ module.exports = withMatrixClient(React.createClass({
if (needsSenderProfile) {
let text = null;
if (!this.props.tileShape || this.props.tileShape === 'quote') {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
if (msgtype === 'm.image') text = _td('%(senderName)s sent an image');
else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video');
else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file');
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} enableFlair={!text} text={text} />;
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={!text}
text={text} />;
} else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={true} />;
}
@ -534,6 +572,40 @@ module.exports = withMatrixClient(React.createClass({
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
const keyRequestHelpText =
<div className="mx_EventTile_keyRequestInfo_tooltip_contents">
<p>
{ this.state.previouslyRequestedKeys ?
_t( 'Your key share request has been sent - please check your other devices ' +
'for key share requests.') :
_t( 'Key share requests are sent to your other devices automatically. If you ' +
'rejected or dismissed the key share request on your other devices, click ' +
'here to request the keys for this session again.')
}
</p>
<p>
{ _t( 'If your other devices do not have the key for this message you will not ' +
'be able to decrypt them.')
}
</p>
</div>;
const keyRequestInfoContent = this.state.previouslyRequestedKeys ?
_t('Key request sent.') :
_t(
'<requestLink>Re-request encryption keys</requestLink> from your other devices.',
{},
{'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a>},
);
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
const keyRequestInfo = isEncryptionFailure ?
<div className="mx_EventTile_keyRequestInfo">
<span className="mx_EventTile_keyRequestInfo_text">
{ keyRequestInfoContent }
</span>
<ToolTipButton helpText={keyRequestHelpText} />
</div> : null;
switch (this.props.tileShape) {
case 'notif': {
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
@ -587,21 +659,27 @@ module.exports = withMatrixClient(React.createClass({
</div>
);
}
case 'quote': {
case 'reply':
case 'reply_preview': {
return (
<div className={classes}>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<div className="mx_EventTile_reply">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{
this.props.tileShape === 'reply_preview'
&& ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread')
}
<EventTileType ref="tile"
tileShape="quote"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onWidgetLoad={this.props.onWidgetLoad}
showUrlPreview={false} />
</div>
</div>
@ -620,12 +698,14 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{ ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
{ keyRequestInfo }
{ editButton }
</div>
</div>
@ -681,7 +761,11 @@ function E2ePadlockUnencrypted(props) {
}
function E2ePadlock(props) {
return <img className="mx_EventTile_e2eIcon" {...props} />;
if (SettingsStore.getValue("alwaysShowEncryptionIcons")) {
return <img className="mx_EventTile_e2eIcon" {...props} />;
} else {
return <img className="mx_EventTile_e2eIcon mx_EventTile_e2eIcon_hidden" {...props} />;
}
}
module.exports.getHandlerTile = getHandlerTile;

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -39,8 +39,8 @@ import Unread from '../../../Unread';
import { findReadReceiptFromUserId } from '../../../utils/Receipt';
import withMatrixClient from '../../../wrappers/withMatrixClient';
import AccessibleButton from '../elements/AccessibleButton';
import GeminiScrollbar from 'react-gemini-scrollbar';
import RoomViewStore from '../../../stores/RoomViewStore';
import SdkConfig from '../../../SdkConfig';
module.exports = withMatrixClient(React.createClass({
displayName: 'MemberInfo',
@ -754,6 +754,7 @@ module.exports = withMatrixClient(React.createClass({
tiles.push(
<RoomTile key={room.roomId} room={room}
transparent={true}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
@ -857,13 +858,27 @@ module.exports = withMatrixClient(React.createClass({
}
const room = this.props.matrixClient.getRoom(this.props.member.roomId);
const poweLevelEvent = room ? room.currentState.getStateEvents("m.room.power_levels", "") : null;
const powerLevelUsersDefault = poweLevelEvent.getContent().users_default;
const powerLevelEvent = room ? room.currentState.getStateEvents("m.room.power_levels", "") : null;
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = this.props.matrixClient.baseUrl;
let showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[hsUrl];
}
let presenceLabel = null;
if (showPresence) {
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive}
presenceState={presenceState} />;
}
let roomMemberDetails = null;
if (this.props.member.roomId) { // is in room
const PowerSelector = sdk.getComponent('elements.PowerSelector');
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
roomMemberDetails = <div>
<div className="mx_MemberInfo_profileField">
{ _t("Level:") } <b>
@ -876,18 +891,17 @@ module.exports = withMatrixClient(React.createClass({
</b>
</div>
<div className="mx_MemberInfo_profileField">
<PresenceLabel activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive}
presenceState={presenceState} />
{presenceLabel}
</div>
</div>;
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<div className="mx_MemberInfo">
<GeminiScrollbar autoshow={true}>
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}> <img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
<div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
@ -911,7 +925,7 @@ module.exports = withMatrixClient(React.createClass({
{ this._renderDevices() }
{ spinner }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,9 +18,9 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index');
const GeminiScrollbar = require('react-gemini-scrollbar');
const rate_limited_func = require('../../../ratelimitedfunc');
const CallHandler = require("../../../CallHandler");
@ -59,6 +59,14 @@ module.exports = React.createClass({
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
// cli.on("Room.timeline", this.onRoomTimeline);
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = MatrixClientPeg.get().baseUrl;
this._showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
this._showPresence = enablePresenceByHsUrl[hsUrl];
}
},
componentWillUnmount: function() {
@ -262,7 +270,7 @@ module.exports = React.createClass({
// console.log("comparing " + this.memberString(memberA) + " and " + this.memberString(memberB));
if (userA.currentlyActive && userB.currentlyActive) {
if ((userA.currentlyActive && userB.currentlyActive) || !this._showPresence) {
// console.log(memberA.name + " and " + memberB.name + " are both active");
if (memberA.powerLevel === memberB.powerLevel) {
// console.log(memberA + " and " + memberB + " have same power level");
@ -315,13 +323,37 @@ module.exports = React.createClass({
});
},
_getPending3PidInvites: function() {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function(e) {
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < requiredKeys.length; ++i) {
if (e.getContent()[requiredKeys[i]] === undefined) return false;
}
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey());
if (memberEvent) return false;
return true;
});
}
},
_makeMemberTiles: function(members, membership) {
const MemberTile = sdk.getComponent("rooms.MemberTile");
const memberList = members.map((userId) => {
const m = this.memberDict[userId];
return (
<MemberTile key={userId} member={m} ref={userId} />
<MemberTile key={userId} member={m} ref={userId} showPresence={this._showPresence} />
);
});
@ -329,33 +361,16 @@ module.exports = React.createClass({
// Double XXX: Now it's really, really not the right home for this logic:
// we shouldn't even be passing in the 'membership' param to this function.
// Ew, ew, and ew.
// Triple XXX: This violates the size constraint, the output is expected/desired
// to be the same length as the members input array.
if (membership === "invite") {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const EntityTile = sdk.getComponent("rooms.EntityTile");
if (room) {
room.currentState.getStateEvents("m.room.third_party_invite").forEach(
function(e) {
// any events without these keys are not valid 3pid invites, so we ignore them
const required_keys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < required_keys.length; ++i) {
if (e.getContent()[required_keys[i]] === undefined) return;
}
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey());
if (memberEvent) {
return;
}
memberList.push(
<EntityTile key={e.getStateKey()} name={e.getContent().display_name} suppressOnHover={true} />,
);
});
}
memberList.push(...this._getPending3PidInvites().map((e) => {
return <EntityTile key={e.getStateKey()}
name={e.getContent().display_name}
suppressOnHover={true}
/>;
}));
}
return memberList;
@ -374,11 +389,12 @@ module.exports = React.createClass({
},
_getChildCountInvited: function() {
return this.state.filteredInvitedMembers.length;
return this.state.filteredInvitedMembers.length + (this._getPending3PidInvites() || []).length;
},
render: function() {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let invitedSection = null;
if (this._getChildCountInvited() > 0) {
@ -407,14 +423,14 @@ module.exports = React.createClass({
return (
<div className="mx_MemberList">
{ inputBox }
<GeminiScrollbar autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper">
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined}
/>
{ invitedSection }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -30,6 +30,13 @@ module.exports = React.createClass({
propTypes: {
member: PropTypes.any.isRequired, // RoomMember
showPresence: PropTypes.bool,
},
getDefaultProps: function() {
return {
showPresence: true,
};
},
getInitialState: function() {
@ -99,7 +106,7 @@ module.exports = React.createClass({
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerStatus={powerStatus} />
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} />
);
},
});

View file

@ -24,7 +24,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
export default class MessageComposer extends React.Component {
constructor(props, context) {
@ -32,8 +32,6 @@ export default class MessageComposer extends React.Component {
this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this);
this.onShowAppsClick = this.onShowAppsClick.bind(this);
this.onHideAppsClick = this.onHideAppsClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
@ -113,6 +111,14 @@ export default class MessageComposer extends React.Component {
</li>);
}
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
let replyToWarning = null;
if (isQuoting) {
replyToWarning = <p>{
_t('At this time it is not possible to reply with a file so this will be sent without being a reply.')
}</p>;
}
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
title: _t('Upload Files'),
description: (
@ -121,6 +127,7 @@ export default class MessageComposer extends React.Component {
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ fileList }
</ul>
{ replyToWarning }
</div>
),
onFinished: (shouldUpload) => {
@ -202,20 +209,6 @@ export default class MessageComposer extends React.Component {
// this._startCallApp(true);
}
onShowAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
}
onHideAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
}
onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({
autocompleteQuery: content,
@ -251,7 +244,7 @@ export default class MessageComposer extends React.Component {
render() {
const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
const uploadInputStyle = {display: 'none'};
const MemberPresenceAvatar = sdk.getComponent('avatars.MemberPresenceAvatar');
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
@ -259,7 +252,7 @@ export default class MessageComposer extends React.Component {
controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberPresenceAvatar member={me} width={24} height={24} />
<MemberAvatar member={me} width={24} height={24} />
</div>,
);
@ -281,7 +274,12 @@ export default class MessageComposer extends React.Component {
alt={e2eTitle} title={e2eTitle}
/>,
);
let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton;
let callButton;
let videoCallButton;
let hangupButton;
// Call buttons
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
<div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
@ -298,19 +296,6 @@ export default class MessageComposer extends React.Component {
</div>;
}
// Apps
if (this.props.showApps) {
hideAppsButton =
<div key="controls_hide_apps" className="mx_MessageComposer_apps" onClick={this.onHideAppsClick} title={_t("Hide Apps")}>
<TintableSvg src="img/icons-hide-apps.svg" width="35" height="35" />
</div>;
} else {
showAppsButton =
<div key="show_apps" className="mx_MessageComposer_apps" onClick={this.onShowAppsClick} title={_t("Show Apps")}>
<TintableSvg src="img/icons-show-apps.svg" width="35" height="35" />
</div>;
}
const canSendMessages = this.props.room.currentState.maySendMessage(
MatrixClientPeg.get().credentials.userId);
@ -353,6 +338,8 @@ export default class MessageComposer extends React.Component {
}
}
const stickerpickerButton = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
controls.push(
<MessageComposerInput
ref={(c) => this.messageComposerInput = c}
@ -364,12 +351,11 @@ export default class MessageComposer extends React.Component {
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
stickerpickerButton,
uploadButton,
hangupButton,
callButton,
videoCallButton,
showAppsButton,
hideAppsButton,
);
} else {
controls.push(

View file

@ -51,9 +51,11 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g')
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
import QuotePreview from "./QuotePreview";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
@ -273,7 +275,7 @@ export default class MessageComposerInput extends React.Component {
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'quote_event':
case 'reply_to_event':
case 'focus_composer':
editor.focus();
break;
@ -723,6 +725,7 @@ export default class MessageComposerInput extends React.Component {
const cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
if (cmd) {
if (!cmd.error) {
this.historyManager.save(contentState, this.state.isRichtextEnabled ? 'html' : 'markdown');
this.setState({
editorState: this.createEditorState(),
});
@ -750,16 +753,14 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const quotingEv = RoomViewStore.getQuotingEvent();
const replyingToEv = RoomViewStore.getQuotingEvent();
const mustSendHTML = Boolean(replyingToEv);
if (this.state.isRichtextEnabled) {
// We should only send HTML if any block is styled or contains inline style
let shouldSendHTML = false;
// If we are quoting we need HTML Content
if (quotingEv) {
shouldSendHTML = true;
}
if (mustSendHTML) shouldSendHTML = true;
const blocks = contentState.getBlocksAsArray();
if (blocks.some((block) => block.getType() !== 'unstyled')) {
@ -819,15 +820,15 @@ export default class MessageComposerInput extends React.Component {
const md = new Markdown(pt);
// if contains no HTML and we're not quoting (needing HTML)
if (md.isPlainText() && !quotingEv) {
if (md.isPlainText() && !mustSendHTML) {
contentText = md.toPlaintext();
} else {
contentHTML = md.toHTML();
}
}
let sendHtmlFn = this.client.sendHtmlMessage;
let sendTextFn = this.client.sendTextMessage;
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(
contentState,
@ -835,45 +836,54 @@ export default class MessageComposerInput extends React.Component {
);
if (contentText.startsWith('/me')) {
if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
title: _t("Unable to reply"),
description: _t("At this time it is not possible to reply with an emote."),
});
return false;
}
contentText = contentText.substring(4);
// bit of a hack, but the alternative would be quite complicated
if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, '');
sendHtmlFn = this.client.sendHtmlEmote;
sendTextFn = this.client.sendEmoteMessage;
sendHtmlFn = ContentHelpers.makeHtmlEmote;
sendTextFn = ContentHelpers.makeEmoteMessage;
}
if (quotingEv) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(quotingEv.getRoomId());
const sender = room.currentState.getMember(quotingEv.getSender());
const {body/*, formatted_body*/} = quotingEv.getContent();
let content = contentHTML ? sendHtmlFn(contentText, contentHTML) : sendTextFn(contentText);
const perma = makeEventPermalink(quotingEv.getRoomId(), quotingEv.getId());
contentText = `${sender.name}:\n> ${body}\n\n${contentText}`;
contentHTML = `<a href="${perma}">Quote<br></a>${contentHTML}`;
if (replyingToEv) {
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
content = Object.assign(replyContent, content);
// we have finished quoting, clear the quotingEvent
// Part of Replies fallback support - prepend the text we're sending with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
}
content.body = nestedReply.body + content.body;
}
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
dis.dispatch({
action: 'quote_event',
action: 'reply_to_event',
event: null,
});
}
let sendMessagePromise;
if (contentHTML) {
sendMessagePromise = sendHtmlFn.call(
this.client, this.props.room.roomId, contentText, contentHTML,
);
} else {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
}
sendMessagePromise.done((res) => {
this.client.sendMessage(this.props.room.roomId, content).then((res) => {
dis.dispatch({
action: 'message_sent',
});
}, (e) => onSendMessageFailed(e, this.props.room));
}).catch((e) => {
onSendMessageFailed(e, this.props.room);
});
this.setState({
editorState: this.createEditorState(),
@ -1172,7 +1182,7 @@ export default class MessageComposerInput extends React.Component {
return (
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_autocomplete_wrapper">
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <QuotePreview /> }
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <ReplyPreview /> }
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}

View file

@ -185,10 +185,21 @@ module.exports = React.createClass({
let title;
if (this.props.timestamp) {
title = _t(
"Seen by %(userName)s at %(dateTime)s",
{userName: this.props.member.userId, dateTime: formatDate(new Date(this.props.timestamp), this.props.showTwelveHour)},
);
const dateString = formatDate(new Date(this.props.timestamp), this.props.showTwelveHour);
if (this.props.member.userId === this.props.member.rawDisplayName) {
title = _t(
"Seen by %(userName)s at %(dateTime)s",
{userName: this.props.member.userId,
dateTime: dateString},
);
} else {
title = _t(
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
{displayName: this.props.member.rawDisplayName,
userName: this.props.member.userId,
dateTime: dateString},
);
}
}
return (

View file

@ -19,15 +19,16 @@ import dis from '../../../dispatcher';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
function cancelQuoting() {
dis.dispatch({
action: 'quote_event',
action: 'reply_to_event',
event: null,
});
}
export default class QuotePreview extends React.Component {
export default class ReplyPreview extends React.Component {
constructor(props, context) {
super(props, context);
@ -61,17 +62,20 @@ export default class QuotePreview extends React.Component {
const EventTile = sdk.getComponent('rooms.EventTile');
const EmojiText = sdk.getComponent('views.elements.EmojiText');
return <div className="mx_QuotePreview">
<div className="mx_QuotePreview_section">
<EmojiText element="div" className="mx_QuotePreview_header mx_QuotePreview_title">
return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<EmojiText element="div" className="mx_ReplyPreview_header mx_ReplyPreview_title">
{ '💬 ' + _t('Replying') }
</EmojiText>
<div className="mx_QuotePreview_header mx_QuotePreview_cancel">
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={cancelQuoting} />
</div>
<div className="mx_QuotePreview_clear" />
<EventTile mxEvent={this.state.event} last={true} tileShape="quote" />
<div className="mx_ReplyPreview_clear" />
<EventTile last={true}
tileShape="reply_preview"
mxEvent={this.state.event}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</div>
</div>;
}

View file

@ -0,0 +1,35 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
module.exports = React.createClass({
displayName: 'RoomDropTarget',
render: function() {
return (
<div className="mx_RoomDropTarget_container">
<div className="mx_RoomDropTarget">
<div className="mx_RoomDropTarget_label">
{ this.props.label }
</div>
</div>
</div>
);
}
});

View file

@ -327,9 +327,8 @@ module.exports = React.createClass({
);
} else if (this.props.room || (this.props.oobData && this.props.oobData.name)) {
roomAvatar = (
<div onClick={this.props.onSettingsClick}>
<RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData} />
</div>
<RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData}
viewAvatarOnClick={true} />
);
}
@ -392,7 +391,7 @@ module.exports = React.createClass({
let manageIntegsButton;
if (this.props.room && this.props.room.roomId && this.props.inRoom) {
manageIntegsButton = <ManageIntegsButton
roomId={this.props.room.roomId}
room={this.props.room}
/>;
}

View file

@ -20,19 +20,20 @@ const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar');
const MatrixClientPeg = require("../../../MatrixClientPeg");
const CallHandler = require('../../../CallHandler');
const dis = require("../../../dispatcher");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
const Rooms = require('../../../Rooms');
import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import RoomListStore from '../../../stores/RoomListStore';
import GroupStore from '../../../stores/GroupStore';
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
function phraseForSection(section) {
switch (section) {
@ -75,19 +76,13 @@ module.exports = React.createClass({
cli.on("Room", this.onRoom);
cli.on("deleteRoom", this.onDeleteRoom);
cli.on("Room.timeline", this.onRoomTimeline);
cli.on("Room.name", this.onRoomName);
cli.on("Room.tags", this.onRoomTags);
cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents);
cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("Event.decrypted", this.onEventDecrypted);
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
const dmRoomMap = DMRoomMap.shared();
this._groupStores = {};
this._groupStoreTokens = [];
// A map between tags which are group IDs and the room IDs of rooms that should be kept
// in the room list when filtering by that tag.
this._visibleRoomsForGroup = {
@ -96,25 +91,29 @@ module.exports = React.createClass({
// All rooms that should be kept in the room list when filtering.
// By default, show all rooms.
this._visibleRooms = MatrixClientPeg.get().getRooms();
// When the selected tags are changed, initialise a group store if necessary
this._tagStoreToken = TagOrderStore.addListener(() => {
// Listen to updates to group data. RoomList cares about members and rooms in order
// to filter the room list when group tags are selected.
this._groupStoreToken = GroupStore.registerListener(null, () => {
(TagOrderStore.getOrderedTags() || []).forEach((tag) => {
if (tag[0] !== '+' || this._groupStores[tag]) {
if (tag[0] !== '+') {
return;
}
this._groupStores[tag] = GroupStoreCache.getGroupStore(tag);
this._groupStoreTokens.push(
this._groupStores[tag].registerListener(() => {
// This group's rooms or members may have updated, update rooms for its tag
this.updateVisibleRoomsForTag(dmRoomMap, tag);
this.updateVisibleRooms();
}),
);
// This group's rooms or members may have updated, update rooms for its tag
this.updateVisibleRoomsForTag(dmRoomMap, tag);
this.updateVisibleRooms();
});
// Filters themselves have changed, refresh the selected tags
});
this._tagStoreToken = TagOrderStore.addListener(() => {
// Filters themselves have changed
this.updateVisibleRooms();
});
this._roomListStoreToken = RoomListStore.addListener(() => {
this._delayedRefreshRoomList();
});
this.refreshRoomList();
// order of the sublists
@ -157,12 +156,6 @@ module.exports = React.createClass({
});
}
break;
case 'on_room_read':
// Force an update because the notif count state is too deep to cause
// an update. This forces the local echo of reading notifs to be
// reflected by the RoomTiles.
this.forceUpdate();
break;
}
},
@ -173,11 +166,7 @@ module.exports = React.createClass({
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
@ -188,9 +177,13 @@ module.exports = React.createClass({
this._tagStoreToken.remove();
}
if (this._groupStoreTokens.length > 0) {
// NB: GroupStore is not a Flux.Store
this._groupStoreTokens.forEach((token) => token.unregister());
if (this._roomListStoreToken) {
this._roomListStoreToken.remove();
}
// NB: GroupStore is not a Flux.Store
if (this._groupStoreToken) {
this._groupStoreToken.unregister();
}
// cancel any pending calls to the rate_limited_funcs
@ -229,13 +222,6 @@ module.exports = React.createClass({
this._updateStickyHeaders(true, scrollToPosition);
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return;
if (!room) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
this._delayedRefreshRoomList();
},
onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us
@ -244,18 +230,6 @@ module.exports = React.createClass({
}
},
onRoomName: function(room) {
this._delayedRefreshRoomList();
},
onRoomTags: function(event, room) {
this._delayedRefreshRoomList();
},
onRoomStateEvents: function(ev, state) {
this._delayedRefreshRoomList();
},
onRoomMemberName: function(ev, member) {
this._delayedRefreshRoomList();
},
@ -283,12 +257,11 @@ module.exports = React.createClass({
updateVisibleRoomsForTag: function(dmRoomMap, tag) {
if (!this.mounted) return;
// For now, only handle group tags
const store = this._groupStores[tag];
if (!store) return;
if (tag[0] !== '+') return;
this._visibleRoomsForGroup[tag] = [];
store.getGroupRooms().forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
store.getGroupMembers().forEach((member) => {
GroupStore.getGroupRooms(tag).forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
GroupStore.getGroupMembers(tag).forEach((member) => {
if (member.userId === MatrixClientPeg.get().credentials.userId) return;
dmRoomMap.getDMRoomsForUserId(member.userId).forEach(
(roomId) => this._visibleRoomsForGroup[tag].push(roomId),
@ -324,12 +297,7 @@ module.exports = React.createClass({
// Show all rooms
this._visibleRooms = MatrixClientPeg.get().getRooms();
}
this.setState({
selectedTags,
}, () => {
this.refreshRoomList();
});
this._delayedRefreshRoomList();
},
refreshRoomList: function() {
@ -343,78 +311,49 @@ module.exports = React.createClass({
totalRooms += l.length;
}
this.setState({
lists: this.getRoomLists(),
lists,
totalRoomCount: totalRooms,
// Do this here so as to not render every time the selected tags
// themselves change.
selectedTags: TagOrderStore.getSelectedTags(),
});
// this._lastRefreshRoomListTs = Date.now();
},
getRoomLists: function() {
const lists = {};
lists["im.vector.fake.invite"] = [];
lists["m.favourite"] = [];
lists["im.vector.fake.recent"] = [];
lists["im.vector.fake.direct"] = [];
lists["m.lowpriority"] = [];
lists["im.vector.fake.archived"] = [];
const lists = RoomListStore.getRoomLists();
const dmRoomMap = DMRoomMap.shared();
const filteredLists = {};
this._visibleRooms.forEach((room, index) => {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me) return;
const isRoomVisible = {
// $roomId: true,
};
// console.log("room = " + room.name + ", me.membership = " + me.membership +
// ", sender = " + me.events.member.getSender() +
// ", target = " + me.events.member.getStateKey() +
// ", prevMembership = " + me.events.member.getPrevContent().membership);
this._visibleRooms.forEach((r) => {
isRoomVisible[r.roomId] = true;
});
if (me.membership == "invite") {
lists["im.vector.fake.invite"].push(room);
} else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, this.props.ConferenceHandler)) {
// skip past this room & don't put it in any lists
} else if (me.membership == "join" || me.membership === "ban" ||
(me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) {
// Used to split rooms via tags
const tagNames = Object.keys(room.tags);
if (tagNames.length) {
for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i];
lists[tagName] = lists[tagName] || [];
lists[tagName].push(room);
}
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged)
lists["im.vector.fake.direct"].push(room);
} else {
lists["im.vector.fake.recent"].push(room);
Object.keys(lists).forEach((tagName) => {
const filteredRooms = lists[tagName].filter((taggedRoom) => {
// Somewhat impossible, but guard against it anyway
if (!taggedRoom) {
return;
}
} else if (me.membership === "leave") {
lists["im.vector.fake.archived"].push(room);
} else {
console.error("unrecognised membership: " + me.membership + " - this should never happen");
const me = taggedRoom.getMember(MatrixClientPeg.get().credentials.userId);
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, me, this.props.ConferenceHandler)) {
return;
}
return Boolean(isRoomVisible[taggedRoom.roomId]);
});
if (filteredRooms.length > 0 || tagName.match(STANDARD_TAGS_REGEX)) {
filteredLists[tagName] = filteredRooms;
}
});
// we actually apply the sorting to this when receiving the prop in RoomSubLists.
// we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down
/*
this.listOrder = [
"im.vector.fake.invite",
"m.favourite",
"im.vector.fake.recent",
"im.vector.fake.direct",
Object.keys(otherTagNames).filter(tagName=>{
return (!tagName.match(/^m\.(favourite|lowpriority)$/));
}).sort(),
"m.lowpriority",
"im.vector.fake.archived"
];
*/
return lists;
return filteredLists;
},
_getScrollNode: function() {
@ -565,7 +504,8 @@ module.exports = React.createClass({
onShowMoreRooms: function() {
// kick gemini in the balls to get it to wake up
// XXX: uuuuuuugh.
this.refs.gemscroll.forceUpdate();
if (!this._gemScroll) return;
this._gemScroll.forceUpdate();
},
_getEmptyContent: function(section) {
@ -583,17 +523,20 @@ module.exports = React.createClass({
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
let tip = null;
switch (section) {
case 'im.vector.fake.direct':
return <div className="mx_RoomList_emptySubListTip">
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"Press <StartChatButton> to start a chat with someone",
{},
{ 'StartChatButton': <StartChatButton size="16" callout={true} /> },
) }
</div>;
break;
case 'im.vector.fake.recent':
return <div className="mx_RoomList_emptySubListTip">
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or"+
" <RoomDirectoryButton> to browse the directory",
@ -604,6 +547,13 @@ module.exports = React.createClass({
},
) }
</div>;
break;
}
if (tip) {
return <div className="mx_RoomList_emptySubListTip_container">
{ tip }
</div>;
}
// We don't want to display drop targets if there are no room tiles to drag'n'drop
@ -646,13 +596,18 @@ module.exports = React.createClass({
return ret;
},
_collectGemini(gemScroll) {
this._gemScroll = gemScroll;
},
render: function() {
const RoomSubList = sdk.getComponent('structures.RoomSubList');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const self = this;
return (
<GeminiScrollbar className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} ref="gemscroll">
<GeminiScrollbarWrapper className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} wrappedRef={this._collectGemini}>
<div className="mx_RoomList">
<RoomSubList list={[]}
extraTiles={this._makeGroupInviteTiles()}
@ -717,7 +672,7 @@ module.exports = React.createClass({
onShowMoreRooms={self.onShowMoreRooms} />
{ Object.keys(self.state.lists).map((tagName) => {
if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
if (!tagName.match(STANDARD_TAGS_REGEX)) {
return <RoomSubList list={self.state.lists[tagName]}
key={tagName}
label={tagName}
@ -758,7 +713,7 @@ module.exports = React.createClass({
searchFilter={self.props.searchFilter}
onShowMoreRooms={self.onShowMoreRooms} />
</div>
</GeminiScrollbar>
</GeminiScrollbarWrapper>
);
},
});

View file

@ -29,13 +29,21 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
name: null,
};
},
componentWillMount: function() {
const room = this.props.room;
const name = room.currentState.getStateEvents('m.room.name', '');
const myId = MatrixClientPeg.get().credentials.userId;
const defaultName = room.getDefaultRoomName(myId);
this._initialName = name ? name.getContent().name : '';
this.setState({
name: name ? name.getContent().name : '',
});
this._placeholderName = _t("Unnamed Room");
if (defaultName && defaultName !== 'Empty room') { // default name from JS SDK, needs no translation as we don't ever show it.
@ -44,7 +52,13 @@ module.exports = React.createClass({
},
getRoomName: function() {
return this.refs.editor.getValue();
return this.state.name;
},
_onValueChanged: function(value, shouldSubmit) {
this.setState({
name: value,
});
},
render: function() {
@ -57,7 +71,8 @@ module.exports = React.createClass({
placeholderClassName="mx_RoomHeader_placeholder"
placeholder={this._placeholderName}
blurToCancel={false}
initialValue={this._initialName}
initialValue={this.state.name}
onValueChanged={this._onValueChanged}
dir="auto" />
</div>
);

View file

@ -117,7 +117,6 @@ module.exports = React.createClass({
propTypes: {
room: PropTypes.object.isRequired,
onSaveClick: PropTypes.func,
},
getInitialState: function() {
@ -132,7 +131,8 @@ module.exports = React.createClass({
join_rule: this._yankValueFromEvent("m.room.join_rules", "join_rule"),
history_visibility: this._yankValueFromEvent("m.room.history_visibility", "history_visibility"),
guest_access: this._yankValueFromEvent("m.room.guest_access", "guest_access"),
power_levels_changed: false,
powerLevels: this._yankContentFromEvent("m.room.power_levels", {}),
powerLevelsChanged: false,
tags_changed: false,
tags: tags,
// isRoomPublished is loaded async in componentWillMount so when the component
@ -151,7 +151,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().getRoomDirectoryVisibility(
this.props.room.roomId,
).done((result) => {
).done((result = {}) => {
this.setState({ isRoomPublished: result.visibility === "public" });
this._originalIsRoomPublished = result.visibility === "public";
}, (err) => {
@ -272,8 +272,8 @@ module.exports = React.createClass({
// power levels
const powerLevels = this._getPowerLevels();
if (powerLevels) {
const powerLevels = this.state.powerLevels;
if (this.state.powerLevelsChanged) {
promises.push(MatrixClientPeg.get().sendStateEvent(
roomId, "m.room.power_levels", powerLevels, "",
));
@ -384,36 +384,32 @@ module.exports = React.createClass({
return strA !== strB;
},
_getPowerLevels: function() {
if (!this.state.power_levels_changed) return undefined;
onPowerLevelsChanged: function(value, powerLevelKey) {
const powerLevels = Object.assign({}, this.state.powerLevels);
const eventsLevelPrefix = "event_levels_";
let powerLevels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
powerLevels = powerLevels ? powerLevels.getContent() : {};
value = parseInt(value);
for (const key of Object.keys(this.refs).filter((k) => k.startsWith("event_levels_"))) {
const eventType = key.substring("event_levels_".length);
powerLevels.events[eventType] = parseInt(this.refs[key].getValue());
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy
powerLevels["events"] = Object.assign({}, this.state.powerLevels["events"] || {});
powerLevels["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value;
} else {
powerLevels[powerLevelKey] = value;
}
const newPowerLevels = {
ban: parseInt(this.refs.ban.getValue()),
kick: parseInt(this.refs.kick.getValue()),
redact: parseInt(this.refs.redact.getValue()),
invite: parseInt(this.refs.invite.getValue()),
events_default: parseInt(this.refs.events_default.getValue()),
state_default: parseInt(this.refs.state_default.getValue()),
users_default: parseInt(this.refs.users_default.getValue()),
users: powerLevels.users,
events: powerLevels.events,
};
return newPowerLevels;
this.setState({
powerLevels,
powerLevelsChanged: true,
});
},
onPowerLevelsChanged: function() {
this.setState({
power_levels_changed: true,
});
_yankContentFromEvent: function(stateEventType, defaultValue) {
// E.g.("m.room.name") would yank the content of "m.room.name"
const event = this.props.room.currentState.getStateEvents(stateEventType, '');
if (!event) {
return defaultValue;
}
return event.getContent() || defaultValue;
},
_yankValueFromEvent: function(stateEventType, keyName, defaultValue) {
@ -633,29 +629,61 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get();
const roomState = this.props.room.currentState;
const user_id = cli.credentials.userId;
const myUserId = cli.credentials.userId;
const power_level_event = roomState.getStateEvents('m.room.power_levels', '');
const power_levels = power_level_event ? power_level_event.getContent() : {};
const events_levels = power_levels.events || {};
const user_levels = power_levels.users || {};
const powerLevels = this.state.powerLevels;
const eventsLevels = powerLevels.events || {};
const userLevels = powerLevels.users || {};
const ban_level = parseIntWithDefault(power_levels.ban, 50);
const kick_level = parseIntWithDefault(power_levels.kick, 50);
const redact_level = parseIntWithDefault(power_levels.redact, 50);
const invite_level = parseIntWithDefault(power_levels.invite, 50);
const send_level = parseIntWithDefault(power_levels.events_default, 0);
const state_level = power_level_event ? parseIntWithDefault(power_levels.state_default, 50) : 0;
const default_user_level = parseIntWithDefault(power_levels.users_default, 0);
const powerLevelDescriptors = {
users_default: {
desc: _t('The default role for new room members is'),
defaultValue: 0,
},
events_default: {
desc: _t('To send messages, you must be a'),
defaultValue: 0,
},
invite: {
desc: _t('To invite users into the room, you must be a'),
defaultValue: 50,
},
state_default: {
desc: _t('To configure the room, you must be a'),
defaultValue: 50,
},
kick: {
desc: _t('To kick users, you must be a'),
defaultValue: 50,
},
ban: {
desc: _t('To ban users, you must be a'),
defaultValue: 50,
},
redact: {
desc: _t('To remove other users\' messages, you must be a'),
defaultValue: 50,
},
};
this._populateDefaultPlEvents(events_levels, state_level, send_level);
const banLevel = parseIntWithDefault(powerLevels.ban, powerLevelDescriptors.ban.defaultValue);
const defaultUserLevel = parseIntWithDefault(
powerLevels.users_default,
powerLevelDescriptors.users_default.defaultValue,
);
let current_user_level = user_levels[user_id];
if (current_user_level === undefined) {
current_user_level = default_user_level;
this._populateDefaultPlEvents(
eventsLevels,
parseIntWithDefault(powerLevels.state_default, powerLevelDescriptors.state_default.defaultValue),
parseIntWithDefault(powerLevels.events_default, powerLevelDescriptors.events_default.defaultValue),
);
let currentUserLevel = userLevels[myUserId];
if (currentUserLevel === undefined) {
currentUserLevel = defaultUserLevel;
}
const can_change_levels = roomState.mayClientSendStateEvent("m.room.power_levels", cli);
const canChangeLevels = roomState.mayClientSendStateEvent("m.room.power_levels", cli);
const canSetTag = !cli.isGuest();
@ -667,29 +695,63 @@ module.exports = React.createClass({
relatedGroupsEvent={this.props.room.currentState.getStateEvents('m.room.related_groups', '')}
/>;
let userLevelsSection;
if (Object.keys(user_levels).length) {
userLevelsSection =
<div>
<h3>{ _t('Privileged Users') }</h3>
<ul className="mx_RoomSettings_userLevels">
{ Object.keys(user_levels).map(function(user, i) {
return (
<li className="mx_RoomSettings_userLevel" key={user}>
{ _t("%(user)s is a", {user: user}) } <PowerSelector value={user_levels[user]} disabled={true} />
</li>
);
let privilegedUsersSection = <div>{ _t('No users have specific privileges in this room') }.</div>; // default
let mutedUsersSection;
if (Object.keys(userLevels).length) {
const privilegedUsers = [];
const mutedUsers = [];
Object.keys(userLevels).forEach(function(user) {
if (userLevels[user] > defaultUserLevel) { // privileged
privilegedUsers.push(<li className="mx_RoomSettings_userLevel" key={user}>
{ _t("%(user)s is a %(userRole)s", {
user: user,
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
}) }
</ul>
</div>;
} else {
userLevelsSection = <div>{ _t('No users have specific privileges in this room') }.</div>;
</li>);
} else if (userLevels[user] < defaultUserLevel) { // muted
mutedUsers.push(<li className="mx_RoomSettings_userLevel" key={user}>
{ _t("%(user)s is a %(userRole)s", {
user: user,
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
}) }
</li>);
}
});
// comparator for sorting PL users lexicographically on PL descending, MXID ascending. (case-insensitive)
const comparator = (a, b) => {
const plDiff = userLevels[b.key] - userLevels[a.key];
return plDiff !== 0 ? plDiff : a.key.toLocaleLowerCase().localeCompare(b.key.toLocaleLowerCase());
};
privilegedUsers.sort(comparator);
mutedUsers.sort(comparator);
if (privilegedUsers.length) {
privilegedUsersSection =
<div>
<h3>{ _t('Privileged Users') }</h3>
<ul className="mx_RoomSettings_userLevels">
{ privilegedUsers }
</ul>
</div>;
}
if (mutedUsers.length) {
mutedUsersSection =
<div>
<h3>{ _t('Muted Users') }</h3>
<ul className="mx_RoomSettings_userLevels">
{ mutedUsers }
</ul>
</div>;
}
}
const banned = this.props.room.getMembersWithMembership("ban");
let bannedUsersSection;
if (banned.length) {
const canBanUsers = current_user_level >= ban_level;
const canBanUsers = currentUserLevel >= banLevel;
bannedUsersSection =
<div>
<h3>{ _t('Banned users') }</h3>
@ -711,13 +773,13 @@ module.exports = React.createClass({
if (this._yankValueFromEvent("m.room.create", "m.federate", true) === false) {
unfederatableSection = (
<div className="mx_RoomSettings_powerLevel">
{ _t('This room is not accessible by remote Matrix servers') }.
{ _t('This room is not accessible by remote Matrix servers') }.
</div>
);
}
let leaveButton = null;
const myMember = this.props.room.getMember(user_id);
const myMember = this.props.room.getMember(myUserId);
if (myMember) {
if (myMember.membership === "join") {
leaveButton = (
@ -774,7 +836,8 @@ module.exports = React.createClass({
const aliasEvents = this.props.room.currentState.getStateEvents('m.room.aliases') || [];
let aliasCount = 0;
aliasEvents.forEach((event) => {
aliasCount += event.getContent().aliases.length;
const aliases = event.getContent().aliases || [];
aliasCount += aliases.length;
});
if (this.state.join_rule === "public" && aliasCount == 0) {
@ -799,6 +862,50 @@ module.exports = React.createClass({
</div>;
}
const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => {
const descriptor = powerLevelDescriptors[key];
const value = parseIntWithDefault(powerLevels[key], descriptor.defaultValue);
return <div key={index} className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">
{ descriptor.desc }
</span>
<PowerSelector
value={value}
usersDefault={defaultUserLevel}
controlled={false}
disabled={!canChangeLevels || currentUserLevel < value}
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
onChange={this.onPowerLevelsChanged}
/>
</div>;
});
const eventPowerSelectors = Object.keys(eventsLevels).map(function(eventType, i) {
let label = plEventsToLabels[eventType];
if (label) {
label = _t(label);
} else {
label = _t(
"To send events of type <eventType/>, you must be a", {},
{ 'eventType': <code>{ eventType }</code> },
);
}
return (
<div className="mx_RoomSettings_powerLevel" key={eventType}>
<span className="mx_RoomSettings_powerLevelKey">{ label } </span>
<PowerSelector
value={eventsLevels[eventType]}
usersDefault={defaultUserLevel}
controlled={false}
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
powerLevelKey={"event_levels_" + eventType}
onChange={self.onPowerLevelsChanged}
/>
</div>
);
});
return (
<div className="mx_RoomSettings">
@ -898,53 +1005,13 @@ module.exports = React.createClass({
<h3>{ _t('Permissions') }</h3>
<div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('The default role for new room members is') } </span>
<PowerSelector ref="users_default" value={default_user_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < default_user_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To send messages, you must be a') } </span>
<PowerSelector ref="events_default" value={send_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < send_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To invite users into the room, you must be a') } </span>
<PowerSelector ref="invite" value={invite_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < invite_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To configure the room, you must be a') } </span>
<PowerSelector ref="state_default" value={state_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < state_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To kick users, you must be a') } </span>
<PowerSelector ref="kick" value={kick_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < kick_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To ban users, you must be a') } </span>
<PowerSelector ref="ban" value={ban_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < ban_level} onChange={this.onPowerLevelsChanged} />
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">{ _t('To remove other users\' messages, you must be a') } </span>
<PowerSelector ref="redact" value={redact_level} usersDefault={default_user_level} controlled={false} disabled={!can_change_levels || current_user_level < redact_level} onChange={this.onPowerLevelsChanged} />
</div>
{ Object.keys(events_levels).map(function(event_type, i) {
let label = plEventsToLabels[event_type];
if (label) label = _t(label);
else label = _t("To send events of type <eventType/>, you must be a", {}, { 'eventType': <code>{ event_type }</code> });
return (
<div className="mx_RoomSettings_powerLevel" key={event_type}>
<span className="mx_RoomSettings_powerLevelKey">{ label } </span>
<PowerSelector ref={"event_levels_"+event_type} value={events_levels[event_type]} usersDefault={default_user_level} onChange={self.onPowerLevelsChanged}
controlled={false} disabled={!can_change_levels || current_user_level < events_levels[event_type]} />
</div>
);
}) }
{ unfederatableSection }
{ powerSelectors }
{ eventPowerSelectors }
{ unfederatableSection }
</div>
{ userLevelsSection }
{ privilegedUsersSection }
{ mutedUsersSection }
{ bannedUsersSection }
<h3>{ _t('Advanced') }</h3>

View file

@ -21,6 +21,7 @@ const React = require('react');
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
const classNames = require('classnames');
import dis from '../../../dispatcher';
const MatrixClientPeg = require('../../../MatrixClientPeg');
import DMRoomMap from '../../../utils/DMRoomMap';
const sdk = require('../../../index');
@ -35,15 +36,14 @@ module.exports = React.createClass({
displayName: 'RoomTile',
propTypes: {
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onClick: PropTypes.func,
isDragging: PropTypes.bool,
room: PropTypes.object.isRequired,
collapsed: PropTypes.bool.isRequired,
unread: PropTypes.bool.isRequired,
highlight: PropTypes.bool.isRequired,
// If true, apply mx_RoomTile_transparent class
transparent: PropTypes.bool,
isInvite: PropTypes.bool.isRequired,
incomingCall: PropTypes.object,
},
@ -59,7 +59,9 @@ module.exports = React.createClass({
hover: false,
badgeHover: false,
menuDisplayed: false,
roomName: this.props.room.name,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
});
},
@ -82,6 +84,20 @@ module.exports = React.createClass({
}
},
onRoomTimeline: function(ev, room) {
if (room !== this.props.room) return;
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
},
onRoomName: function(room) {
if (room !== this.props.room) return;
this.setState({
roomName: this.props.room.name,
});
},
onAccountData: function(accountDataEvent) {
if (accountDataEvent.getType() == 'm.push_rules') {
this.setState({
@ -90,6 +106,21 @@ module.exports = React.createClass({
}
},
onAction: function(payload) {
switch (payload.action) {
// XXX: slight hack in order to zero the notification count when a room
// is read. Ideally this state would be given to this via props (as we
// do with `unread`). This is still better than forceUpdating the entire
// RoomList when a room is read.
case 'on_room_read':
if (payload.roomId !== this.props.room.roomId) break;
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
break;
}
},
_onActiveRoomChange: function() {
this.setState({
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
@ -98,15 +129,46 @@ module.exports = React.createClass({
componentWillMount: function() {
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef);
},
componentWillReceiveProps: function(props) {
// XXX: This could be a lot better - this makes the assumption that
// the notification count may have changed when the properties of
// the room tile change.
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
},
// Do a simple shallow comparison of props and state to avoid unnecessary
// renders. The assumption made here is that only state and props are used
// in rendering this component and children.
//
// RoomList is frequently made to forceUpdate, so this decreases number of
// RoomTile renderings.
shouldComponentUpdate: function(newProps, newState) {
if (Object.keys(newProps).some((k) => newProps[k] !== this.props[k])) {
return true;
}
if (Object.keys(newState).some((k) => newState[k] !== this.state[k])) {
return true;
}
return false;
},
onClick: function(ev) {
@ -175,7 +237,7 @@ module.exports = React.createClass({
const myUserId = MatrixClientPeg.get().credentials.userId;
const me = this.props.room.currentState.members[myUserId];
const notificationCount = this.props.room.getUnreadNotificationCount();
const notificationCount = this.state.notificationCount;
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge();
@ -191,6 +253,7 @@ module.exports = React.createClass({
'mx_RoomTile_invited': (me && me.membership == 'invite'),
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
});
const avatarClasses = classNames({
@ -202,9 +265,7 @@ module.exports = React.createClass({
'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menuDisplayed,
});
// XXX: We should never display raw room IDs, but sometimes the
// room name js sdk gives is undefined (cannot repro this -- k)
let name = this.props.room.name || this.props.room.roomId;
let name = this.state.roomName;
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badge;
@ -256,35 +317,19 @@ module.exports = React.createClass({
directMessageIndicator = <img src="img/icon_person.svg" className="mx_RoomTile_dm" width="11" height="13" alt="dm" />;
}
// These props are injected by React DnD,
// as defined by your `collect` function above:
const isDragging = this.props.isDragging;
const connectDragSource = this.props.connectDragSource;
const connectDropTarget = this.props.connectDropTarget;
let ret = (
<div> { /* Only native elements can be wrapped in a DnD object. */ }
<AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ directMessageIndicator }
</div>
return <AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ directMessageIndicator }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
</div>
);
if (connectDropTarget) ret = connectDropTarget(ret);
if (connectDragSource) ret = connectDragSource(ret);
return ret;
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>;
},
});

View file

@ -0,0 +1,120 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var dis = require('../../../dispatcher');
import classNames from 'classnames';
const MIN_TOOLTIP_HEIGHT = 25;
module.exports = React.createClass({
displayName: 'RoomTooltip',
propTypes: {
// Class applied to the element used to position the tooltip
className: React.PropTypes.string.isRequired,
// Class applied to the tooltip itself
tooltipClassName: React.PropTypes.string,
// The tooltip is derived from either the room name or a label
room: React.PropTypes.object,
label: React.PropTypes.node,
},
// Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() {
this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_RoomTileTooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this._renderTooltip, true);
this.parent = ReactDOM.findDOMNode(this).parentNode;
this._renderTooltip();
},
componentDidUpdate: function() {
this._renderTooltip();
},
// Remove the wrapper element, as the tooltip has finished using it
componentWillUnmount: function() {
dis.dispatch({
action: 'view_tooltip',
tooltip: null,
parent: null,
});
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this._renderTooltip, true);
},
_updatePosition(style) {
const parentBox = this.parent.getBoundingClientRect();
let offset = 0;
if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
offset = Math.floor((parentBox.height - MIN_TOOLTIP_HEIGHT) / 2);
}
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
style.left = 6 + parentBox.right + window.pageXOffset;
return style;
},
_renderTooltip: function() {
var label = this.props.room ? this.props.room.name : this.props.label;
// Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom
// NOTE: The additional 6 pixels for the left position, is to take account of the
// tooltips chevron
var parent = ReactDOM.findDOMNode(this).parentNode;
var style = {};
style = this._updatePosition(style);
style.display = "block";
const tooltipClasses = classNames(
"mx_RoomTooltip", this.props.tooltipClassName,
);
var tooltip = (
<div className={tooltipClasses} style={style} >
<div className="mx_RoomTooltip_chevron"></div>
{ label }
</div>
);
// Render the tooltip manually, as we wish it not to be rendered within the parent
this.tooltip = ReactDOM.render(tooltip, this.tooltipContainer);
// Tell the roomlist about us so it can manipulate us if it wishes
dis.dispatch({
action: 'view_tooltip',
tooltip: this.tooltip,
parent: parent,
});
},
render: function() {
// Render a placeholder
return (
<div className={this.props.className} >
</div>
);
},
});

View file

@ -28,26 +28,41 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
topic: null,
};
},
componentWillMount: function() {
const room = this.props.room;
const topic = room.currentState.getStateEvents('m.room.topic', '');
this._initialTopic = topic ? topic.getContent().topic : '';
this.setState({
topic: topic ? topic.getContent().topic : '',
});
},
getTopic: function() {
return this.refs.editor.getValue();
return this.state.topic;
},
_onValueChanged: function(value) {
this.setState({
topic: value,
});
},
render: function() {
const EditableText = sdk.getComponent("elements.EditableText");
return (
<EditableText ref="editor"
<EditableText
className="mx_RoomHeader_topic mx_RoomHeader_editable"
placeholderClassName="mx_RoomHeader_placeholder"
placeholder={_t("Add a topic")}
blurToCancel={false}
initialValue={this._initialTopic}
initialValue={this.state.topic}
onValueChanged={this._onValueChanged}
dir="auto" />
);
},

View file

@ -0,0 +1,71 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var classNames = require('classnames');
var AccessibleButton = require('../../../components/views/elements/AccessibleButton');
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'SearchBar',
getInitialState: function() {
return ({
scope: 'Room'
});
},
onThisRoomClick: function() {
this.setState({ scope: 'Room' });
},
onAllRoomsClick: function() {
this.setState({ scope: 'All' });
},
onSearchChange: function(e) {
if (e.keyCode === 13) { // on enter...
this.onSearch();
}
if (e.keyCode === 27) { // escape...
this.props.onCancelClick();
}
},
onSearch: function() {
this.props.onSearch(this.refs.search_term.value, this.state.scope);
},
render: function() {
var searchButtonClasses = classNames({ mx_SearchBar_searchButton : true, mx_SearchBar_searching: this.props.searchInProgress });
var thisRoomClasses = classNames({ mx_SearchBar_button : true, mx_SearchBar_unselected : this.state.scope !== 'Room' });
var allRoomsClasses = classNames({ mx_SearchBar_button : true, mx_SearchBar_unselected : this.state.scope !== 'All' });
return (
<div className="mx_SearchBar">
<input ref="search_term" className="mx_SearchBar_input" type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange}/>
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch}><img src="img/search-button.svg" width="37" height="37" alt={_t("Search")}/></AccessibleButton>
<AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick}>{_t("This Room")}</AccessibleButton>
<AccessibleButton className={ allRoomsClasses } onClick={this.onAllRoomsClick}>{_t("All Rooms")}</AccessibleButton>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
</div>
);
}
});

View file

@ -19,7 +19,6 @@ const MatrixClientPeg = require("../../../MatrixClientPeg");
const Modal = require("../../../Modal");
const sdk = require("../../../index");
import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar');
// A list capable of displaying entities which conform to the SearchableEntity
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
@ -164,11 +163,12 @@ const SearchableEntityList = React.createClass({
</div>
);
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
list = (
<GeminiScrollbar autoshow={true}
<GeminiScrollbarWrapper autoshow={true}
className="mx_SearchableEntityList_listWrapper">
{ list }
</GeminiScrollbar>
</GeminiScrollbarWrapper>
);
}

View file

@ -0,0 +1,377 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import Widgets from '../../../utils/widgets';
import AppTile from '../elements/AppTile';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
const widgetType = 'm.stickerpicker';
export default class Stickerpicker extends React.Component {
constructor(props) {
super(props);
this._onShowStickersClick = this._onShowStickersClick.bind(this);
this._onHideStickersClick = this._onHideStickersClick.bind(this);
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
this._updateWidget = this._updateWidget.bind(this);
this._onWidgetAction = this._onWidgetAction.bind(this);
this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);
this._collectWidgetMessaging = this._collectWidgetMessaging.bind(this);
this.popoverWidth = 300;
this.popoverHeight = 300;
this.state = {
showStickers: false,
imError: null,
stickerpickerX: null,
stickerpickerY: null,
stickerpickerWidget: null,
widgetId: null,
};
}
_removeStickerpickerWidgets() {
console.warn('Removing Stickerpicker widgets');
if (this.state.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
});
} else {
console.warn('No widget ID specified, not disabling assets');
}
this.setState({showStickers: false});
Widgets.removeStickerpickerWidgets().then(() => {
this.forceUpdate();
}).catch((e) => {
console.error('Failed to remove sticker picker widget', e);
});
}
componentDidMount() {
// Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize);
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
this._imError("Failed to connect to integrations server", e);
});
}
if (!this.state.imError) {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
// Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this._updateWidget);
// Initialise widget state from current account data
this._updateWidget();
}
componentWillUnmount() {
window.removeEventListener('resize', this._onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
componentDidUpdate(prevProps, prevState) {
this._sendVisibilityToWidget(this.state.showStickers);
}
_imError(errorMsg, e) {
console.error(errorMsg, e);
this.setState({
showStickers: false,
imError: errorMsg,
});
}
_updateWidget() {
const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0];
this.setState({
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
});
}
_onWidgetAction(payload) {
switch (payload.action) {
case "user_widget_updated":
this.forceUpdate();
break;
case "stickerpicker_close":
this.setState({showStickers: false});
break;
case "show_right_panel":
case "hide_right_panel":
case "show_left_panel":
case "hide_left_panel":
this.setState({showStickers: false});
break;
}
}
_defaultStickerpickerContent() {
return (
<AccessibleButton onClick={this._launchManageIntegrations}
className='mx_Stickers_contentPlaceholder'>
<p>{ _t("You don't currently have any stickerpacks enabled") }</p>
<p className='mx_Stickers_addLink'>Add some now</p>
<img src='img/stickerpack-placeholder.png' alt={_t('Add a stickerpack')} />
</AccessibleButton>
);
}
_errorStickerpickerContent() {
return (
<div style={{"text-align": "center"}} className="error">
<p> { this.state.imError } </p>
</div>
);
}
_collectWidgetMessaging(widgetMessaging) {
this._appWidgetMessaging = widgetMessaging;
// Do this now instead of in componentDidMount because we might not have had the
// reference to widgetMessaging when mounting
this._sendVisibilityToWidget(true);
}
_sendVisibilityToWidget(visible) {
if (this._appWidgetMessaging && visible !== this._prevSentVisibility) {
this._appWidgetMessaging.sendVisibility(visible);
this._prevSentVisibility = visible;
}
}
_getStickerpickerContent() {
// Handle Integration Manager errors
if (this.state._imError) {
return this._errorStickerpickerContent();
}
// Stickers
// TODO - Add support for Stickerpickers from multiple app stores.
// Render content from multiple stickerpack sources, each within their
// own iframe, within the stickerpicker UI element.
const stickerpickerWidget = this.state.stickerpickerWidget;
let stickersContent;
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
// updated.
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Load stickerpack content
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
stickersContent = (
<div className='mx_Stickers_content_container'>
<div
id='stickersContent'
className='mx_Stickers_content'
style={{
border: 'none',
height: this.popoverHeight,
width: this.popoverWidth,
}}
>
<PersistedElement>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
room={this.props.room}
type={stickerpickerWidget.content.type}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
show={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showMinimise={true}
showDelete={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true}
/>
</PersistedElement>
</div>
</div>
);
} else {
// Default content to show if stickerpicker widget not added
stickersContent = this._defaultStickerpickerContent();
}
return stickersContent;
}
/**
* Show the sticker picker overlay
* If no stickerpacks have been added, show a link to the integration manager add sticker packs page.
* @param {Event} e Event that triggered the function
*/
_onShowStickersClick(e) {
// XXX: Simplify by using a context menu that is positioned relative to the sticker picker button
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
let x = buttonRect.right + window.pageXOffset - 41;
// Amount of horizontal space between the right of menu and the right of the viewport
// (10 = amount needed to make chevron centrally aligned)
const rightPad = 10;
// When the sticker picker would be displayed off of the viewport, adjust x
// (302 = width of context menu, including borders)
x = Math.min(x, document.body.clientWidth - (302 + rightPad));
// Offset the chevron location, which is relative to the left of the context menu
// (10 = offset when context menu would not be displayed off viewport)
// (8 = value required in practice (possibly 10 - 2 where the 2 = context menu borders)
const stickerPickerChevronOffset = Math.max(10, 8 + window.pageXOffset + buttonRect.left - x);
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
this.setState({
showStickers: true,
stickerPickerX: x,
stickerPickerY: y,
stickerPickerChevronOffset,
});
}
/**
* Trigger hiding of the sticker picker overlay
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
this.setState({showStickers: false});
}
/**
* Called when the window is resized
*/
_onResize() {
this.setState({showStickers: false});
}
/**
* The stickers picker was hidden
*/
_onFinished() {
this.setState({showStickers: false});
}
/**
* Launch the integrations manager on the stickers integration page
*/
_launchManageIntegrations() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
this.state.widgetId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const ContextualMenu = sdk.getComponent('structures.ContextualMenu');
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
let stickersButton;
const stickerPicker = <ContextualMenu
elementClass={GenericElementContextMenu}
chevronOffset={this.state.stickerPickerChevronOffset}
chevronFace={'bottom'}
left={this.state.stickerPickerX}
top={this.state.stickerPickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
element={this._getStickerpickerContent()}
onFinished={this._onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
/>;
if (this.state.showStickers) {
// Show hide-stickers button
stickersButton =
<div
id='stickersButton'
key="controls_hide_stickers"
className="mx_MessageComposer_stickers mx_Stickers_hideStickers"
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src="img/icons-hide-stickers.svg" width="35" height="35" />
</div>;
} else {
// Show show-stickers button
stickersButton =
<div
id='stickersButton'
key="constrols_show_stickers"
className="mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src="img/icons-show-stickers.svg" width="35" height="35" />
</div>;
}
return <div>
{stickersButton}
{this.state.showStickers && stickerPicker}
</div>;
}
}

View file

@ -20,6 +20,7 @@ limitations under the License.
const React = require('react');
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
const sdk = require('../../../index');
module.exports = React.createClass({
@ -33,14 +34,19 @@ module.exports = React.createClass({
render: function() {
return (
<div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp"
<AccessibleButton className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollto.svg" width="24" height="24"
alt={_t('Scroll to unread messages')}
title={_t('Scroll to unread messages')} />
// No point on setting up non empty alt on this image
// as it only complements the text which follows it.
alt=""
title={_t('Scroll to unread messages')}
// In order not to use this title attribute for accessible name
// calculation of the parent button set the role presentation
role="presentation" />
{ _t("Jump to first unread message.") }
</div>
<img className="mx_TopUnreadMessagesBar_close mx_filterFlipColor"
</AccessibleButton>
<AccessibleButton element='img' className="mx_TopUnreadMessagesBar_close mx_filterFlipColor"
src="img/cancel.svg" width="18" height="18"
alt={_t("Close")} title={_t("Close")}
onClick={this.props.onCloseClick} />

View file

@ -0,0 +1,63 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var dis = require('../../../dispatcher');
module.exports = React.createClass({
displayName: 'IntegrationsManager',
propTypes: {
src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
},
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
// dialog base class somehow, surely...
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown);
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
},
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
},
onAction: function(payload) {
if (payload.action === 'close_scalar') {
this.props.onFinished();
}
},
render: function() {
return (
<iframe src={ this.props.src }></iframe>
);
}
});

View file

@ -0,0 +1,919 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import Promise from 'bluebird';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import UserSettingsStore from '../../../UserSettingsStore';
import SettingsStore, {SettingLevel} from '../../../settings/SettingsStore';
import Modal from '../../../Modal';
import {
NotificationUtils,
VectorPushRulesDefinitions,
PushRuleVectorState,
ContentRules
} from '../../../notifications';
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
// TODO: this component also does a lot of direct poking into this.state, which
// is VERY NAUGHTY.
/**
* Rules that Vector used to set in order to override the actions of default rules.
* These are used to port peoples existing overrides to match the current API.
* These can be removed and forgotten once everyone has moved to the new client.
*/
const LEGACY_RULES = {
"im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
"im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
"im.vector.rule.room_message": ".m.rule.message",
"im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
"im.vector.rule.call": ".m.rule.call",
"im.vector.rule.notices": ".m.rule.suppress_notices"
};
function portLegacyActions(actions) {
const decoded = NotificationUtils.decodeActions(actions);
if (decoded !== null) {
return NotificationUtils.encodeActions(decoded);
} else {
// We don't recognise one of the actions here, so we don't try to
// canonicalise them.
return actions;
}
}
module.exports = React.createClass({
displayName: 'Notifications',
phases: {
LOADING: "LOADING", // The component is loading or sending data to the hs
DISPLAY: "DISPLAY", // The component is ready and display data
ERROR: "ERROR" // There was an error
},
propTypes: {
// The array of threepids from the JS SDK (required for email notifications)
threepids: React.PropTypes.array.isRequired,
// The brand string set when creating an email pusher
brand: React.PropTypes.string,
},
getDefaultProps: function() {
return {
threepids: []
};
},
getInitialState: function() {
return {
phase: this.phases.LOADING,
masterPushRule: undefined, // The master rule ('.m.rule.master')
vectorPushRules: [], // HS default push rules displayed in Vector UI
vectorContentRules: { // Keyword push rules displayed in Vector UI
vectorState: PushRuleVectorState.ON,
rules: []
},
externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
externalContentRules: [] // Keyword push rules that have been defined outside Vector UI
};
},
componentWillMount: function() {
this._refreshFromServer();
},
onEnableNotificationsChange: function(event) {
const self = this;
this.setState({
phase: this.phases.LOADING
});
MatrixClientPeg.get().setPushRuleEnabled('global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !event.target.checked).done(function() {
self._refreshFromServer();
});
},
onEnableDesktopNotificationsChange: function(event) {
SettingsStore.setValue(
"notificationsEnabled", null,
SettingLevel.DEVICE,
event.target.checked,
).finally(() => {
this.forceUpdate();
});
},
onEnableDesktopNotificationBodyChange: function(event) {
SettingsStore.setValue(
"notificationBodyEnabled", null,
SettingLevel.DEVICE,
event.target.checked,
).finally(() => {
this.forceUpdate();
});
},
onEnableAudioNotificationsChange: function(event) {
SettingsStore.setValue(
"audioNotificationsEnabled", null,
SettingLevel.DEVICE,
event.target.checked,
).finally(() => {
this.forceUpdate();
});
},
onEnableEmailNotificationsChange: function(address, event) {
let emailPusherPromise;
if (event.target.checked) {
const data = {}
data['brand'] = this.props.brand || 'Riot';
emailPusherPromise = UserSettingsStore.addEmailPusher(address, data);
} else {
const emailPusher = UserSettingsStore.getEmailPusher(this.state.pushers, address);
emailPusher.kind = null;
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
}
emailPusherPromise.done(() => {
this._refreshFromServer();
}, (error) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
title: _t('Error saving email notification preferences'),
description: _t('An error occurred whilst saving your email notification preferences.'),
});
});
},
onNotifStateButtonClicked: function(event) {
// FIXME: use .bind() rather than className metadata here surely
const vectorRuleId = event.target.className.split("-")[0];
const newPushRuleVectorState = event.target.className.split("-")[1];
if ("_keywords" === vectorRuleId) {
this._setKeywordsPushRuleVectorState(newPushRuleVectorState)
}
else {
const rule = this.getRule(vectorRuleId);
if (rule) {
this._setPushRuleVectorState(rule, newPushRuleVectorState);
}
}
},
onKeywordsClicked: function(event) {
const self = this;
// Compute the keywords list to display
let keywords = [];
for (let i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
keywords.push(rule.pattern);
}
if (keywords.length) {
// As keeping the order of per-word push rules hs side is a bit tricky to code,
// display the keywords in alphabetical order to the user
keywords.sort();
keywords = keywords.join(", ");
}
else {
keywords = "";
}
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
title: _t('Keywords'),
description: _t('Enter keywords separated by a comma:'),
button: _t('OK'),
value: keywords,
onFinished: function onFinished(should_leave, newValue) {
if (should_leave && newValue !== keywords) {
let newKeywords = newValue.split(',');
for (let i in newKeywords) {
newKeywords[i] = newKeywords[i].trim();
}
// Remove duplicates and empty
newKeywords = newKeywords.reduce(function(array, keyword){
if (keyword !== "" && array.indexOf(keyword) < 0) {
array.push(keyword);
}
return array;
},[]);
self._setKeywords(newKeywords);
}
}
});
},
getRule: function(vectorRuleId) {
for (let i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.vectorRuleId === vectorRuleId) {
return rule;
}
}
},
_setPushRuleVectorState: function(rule, newPushRuleVectorState) {
if (rule && rule.vectorState !== newPushRuleVectorState) {
this.setState({
phase: this.phases.LOADING
});
const self = this;
const cli = MatrixClientPeg.get();
const deferreds = [];
const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
if (rule.rule) {
const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
if (!actions) {
// The new state corresponds to disabling the rule.
deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
}
else {
// The new state corresponds to enabling the rule and setting specific actions
deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
}
}
Promise.all(deferreds).done(function() {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change settings: " + error);
Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
title: _t('Failed to change settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer
});
});
}
},
_setKeywordsPushRuleVectorState: function(newPushRuleVectorState) {
// Is there really a change?
if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
|| this.state.vectorContentRules.rules.length === 0) {
return;
}
const self = this;
const cli = MatrixClientPeg.get();
this.setState({
phase: this.phases.LOADING
});
// Update all rules in self.state.vectorContentRules
const deferreds = [];
for (let i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
let enabled, actions;
switch (newPushRuleVectorState) {
case PushRuleVectorState.ON:
if (rule.actions.length !== 1) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.LOUD:
if (rule.actions.length !== 3) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.OFF:
enabled = false;
break;
}
if (actions) {
// Note that the workaround in _updatePushRuleActions will automatically
// enable the rule
deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
}
else if (enabled != undefined) {
deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
}
}
Promise.all(deferreds).done(function(resps) {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Can't update user notification settings: " + error);
Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
title: _t('Can\'t update user notification settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer
});
});
},
_setKeywords: function(newKeywords) {
this.setState({
phase: this.phases.LOADING
});
const self = this;
const cli = MatrixClientPeg.get();
const removeDeferreds = [];
// Remove per-word push rules of keywords that are no more in the list
const vectorContentRulesPatterns = [];
for (let i in self.state.vectorContentRules.rules) {
const rule = self.state.vectorContentRules.rules[i];
vectorContentRulesPatterns.push(rule.pattern);
if (newKeywords.indexOf(rule.pattern) < 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
// If the keyword is part of `externalContentRules`, remove the rule
// before recreating it in the right Vector path
for (let i in self.state.externalContentRules) {
const rule = self.state.externalContentRules[i];
if (newKeywords.indexOf(rule.pattern) >= 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
const onError = function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to update keywords: " + error);
Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
title: _t('Failed to update keywords'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer
});
}
// Then, add the new ones
Promise.all(removeDeferreds).done(function(resps) {
const deferreds = [];
let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of rules in 'vectorContentRules' to apply the same actions
// when creating the new rule.
// Thus, this new rule will join the 'vectorContentRules' set.
if (self.state.vectorContentRules.rules.length) {
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(self.state.vectorContentRules.rules[0]);
}
else {
// ON is default
pushRuleVectorStateKind = PushRuleVectorState.ON;
}
}
for (let i in newKeywords) {
const keyword = newKeywords[i];
if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
deferreds.push(cli.addPushRule
('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword
}));
}
else {
deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword
}));
}
}
}
Promise.all(deferreds).done(function(resps) {
self._refreshFromServer();
}, onError);
}, onError);
},
// Create a push rule but disabled
_addDisabledPushRule: function(scope, kind, ruleId, body) {
const cli = MatrixClientPeg.get();
return cli.addPushRule(scope, kind, ruleId, body).then(() =>
cli.setPushRuleEnabled(scope, kind, ruleId, false)
);
},
// Check if any legacy im.vector rules need to be ported to the new API
// for overriding the actions of default rules.
_portRulesToNewAPI: function(rulesets) {
const self = this;
const needsUpdate = [];
const cli = MatrixClientPeg.get();
for (let kind in rulesets.global) {
const ruleset = rulesets.global[kind];
for (let i = 0; i < ruleset.length; ++i) {
const rule = ruleset[i];
if (rule.rule_id in LEGACY_RULES) {
console.log("Porting legacy rule", rule);
needsUpdate.push( function(kind, rule) {
return cli.setPushRuleActions(
'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions)
).then(() =>
cli.deletePushRule('global', kind, rule.rule_id)
).catch( (e) => {
console.warn(`Error when porting legacy rule: ${e}`);
});
}(kind, rule));
}
}
}
if (needsUpdate.length > 0) {
// If some of the rules need to be ported then wait for the porting
// to happen and then fetch the rules again.
return Promise.all(needsUpdate).then(() =>
cli.getPushRules()
);
} else {
// Otherwise return the rules that we already have.
return rulesets;
}
},
_refreshFromServer: function() {
const self = this;
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(self._portRulesToNewAPI).then(function(rulesets) {
/// XXX seriously? wtf is this?
MatrixClientPeg.get().pushRules = rulesets;
// Get homeserver default rules and triage them by categories
const rule_categories = {
// The master rule (all notifications disabling)
'.m.rule.master': 'master',
// The default push rules displayed by Vector UI
'.m.rule.contains_display_name': 'vector',
'.m.rule.contains_user_name': 'vector',
'.m.rule.room_one_to_one': 'vector',
'.m.rule.message': 'vector',
'.m.rule.invite_for_me': 'vector',
//'.m.rule.member_event': 'vector',
'.m.rule.call': 'vector',
'.m.rule.suppress_notices': 'vector'
// Others go to others
};
// HS default rules
const defaultRules = {master: [], vector: {}, others: []};
for (let kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
const cat = rule_categories[r.rule_id];
r.kind = kind;
if (r.rule_id[0] === '.') {
if (cat === 'vector') {
defaultRules.vector[r.rule_id] = r;
}
else if (cat === 'master') {
defaultRules.master.push(r);
}
else {
defaultRules['others'].push(r);
}
}
}
}
// Get the master rule if any defined by the hs
if (defaultRules.master.length > 0) {
self.state.masterPushRule = defaultRules.master[0];
}
// parse the keyword rules into our state
const contentRules = ContentRules.parseContentRules(rulesets);
self.state.vectorContentRules = {
vectorState: contentRules.vectorState,
rules: contentRules.rules,
};
self.state.externalContentRules = contentRules.externalRules;
// Build the rules displayed in the Vector UI matrix table
self.state.vectorPushRules = [];
self.state.externalPushRules = [];
const vectorRuleIds = [
'.m.rule.contains_display_name',
'.m.rule.contains_user_name',
'_keywords',
'.m.rule.room_one_to_one',
'.m.rule.message',
'.m.rule.invite_for_me',
//'im.vector.rule.member_event',
'.m.rule.call',
'.m.rule.suppress_notices'
];
for (let i in vectorRuleIds) {
const vectorRuleId = vectorRuleIds[i];
if (vectorRuleId === '_keywords') {
// keywords needs a special handling
// For Vector UI, this is a single global push rule but translated in Matrix,
// it corresponds to all content push rules (stored in self.state.vectorContentRule)
self.state.vectorPushRules.push({
"vectorRuleId": "_keywords",
"description" : (
<span>
{ _t('Messages containing <span>keywords</span>',
{},
{ 'span': (sub) =>
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>
},
)}
</span>
),
"vectorState": self.state.vectorContentRules.vectorState
});
}
else {
const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
const rule = defaultRules.vector[vectorRuleId];
const vectorState = ruleDefinition.ruleToVectorState(rule);
//console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
self.state.vectorPushRules.push({
"vectorRuleId": vectorRuleId,
"description" : _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
"rule": rule,
"vectorState": vectorState,
});
// if there was a rule which we couldn't parse, add it to the external list
if (rule && !vectorState) {
rule.description = ruleDefinition.description;
self.state.externalPushRules.push(rule);
}
}
}
// Build the rules not managed by Vector UI
const otherRulesDescriptions = {
'.m.rule.message': _t('Notify for all other messages/rooms'),
'.m.rule.fallback': _t('Notify me for anything else'),
};
for (let i in defaultRules.others) {
const rule = defaultRules.others[i];
const ruleDescription = otherRulesDescriptions[rule.rule_id];
// Show enabled default rules that was modified by the user
if (ruleDescription && rule.enabled && !rule.default) {
rule.description = ruleDescription;
self.state.externalPushRules.push(rule);
}
}
});
const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
self.setState({pushers: resp.pushers});
});
Promise.all([pushRulesPromise, pushersPromise]).then(function() {
self.setState({
phase: self.phases.DISPLAY
});
}, function(error) {
console.error(error);
self.setState({
phase: self.phases.ERROR
});
}).finally(() => {
// actually explicitly update our state having been deep-manipulating it
self.setState({
masterPushRule: self.state.masterPushRule,
vectorContentRules: self.state.vectorContentRules,
vectorPushRules: self.state.vectorPushRules,
externalContentRules: self.state.externalContentRules,
externalPushRules: self.state.externalPushRules,
});
}).done();
},
_updatePushRuleActions: function(rule, actions, enabled) {
const cli = MatrixClientPeg.get();
return cli.setPushRuleActions(
'global', rule.kind, rule.rule_id, actions
).then( function() {
// Then, if requested, enabled or disabled the rule
if (undefined != enabled) {
return cli.setPushRuleEnabled(
'global', rule.kind, rule.rule_id, enabled
);
}
});
},
renderNotifRulesTableRow: function(title, className, pushRuleVectorState) {
return (
<tr key={ className }>
<th>
{ title }
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.OFF}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.OFF }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.ON}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.ON }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.LOUD}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.LOUD }
onChange={ this.onNotifStateButtonClicked } />
</th>
</tr>
);
},
renderNotifRulesTableRows: function() {
const rows = [];
for (let i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
}
return rows;
},
emailNotificationsRow: function(address, label) {
return (<div className="mx_UserNotifSettings_tableRow">
<div className="mx_UserNotifSettings_inputCell">
<input id="enableEmailNotifications_{address}"
ref="enableEmailNotifications_{address}"
type="checkbox"
checked={ UserSettingsStore.hasEmailPusher(this.state.pushers, address) }
onChange={ this.onEnableEmailNotificationsChange.bind(this, address) }
/>
</div>
<div className="mx_UserNotifSettings_labelCell">
<label htmlFor="enableEmailNotifications_{address}">
{label}
</label>
</div>
</div>);
},
render: function() {
const self = this;
let spinner;
if (this.state.phase === this.phases.LOADING) {
const Loader = sdk.getComponent("elements.Spinner");
spinner = <Loader />;
}
let masterPushRuleDiv;
if (this.state.masterPushRule) {
masterPushRuleDiv = (
<div className="mx_UserNotifSettings_tableRow">
<div className="mx_UserNotifSettings_inputCell">
<input id="enableNotifications"
ref="enableNotifications"
type="checkbox"
checked={ !this.state.masterPushRule.enabled }
onChange={ this.onEnableNotificationsChange }
/>
</div>
<div className="mx_UserNotifSettings_labelCell">
<label htmlFor="enableNotifications">
{ _t('Enable notifications for this account') }
</label>
</div>
</div>
);
}
// When enabled, the master rule inhibits all existing rules
// So do not show all notification settings
if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserSettings_notifTable">
{ _t('All notifications are currently disabled for all targets.') }.
</div>
</div>
);
}
const emailThreepids = this.props.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRow;
if (emailThreepids.length === 0) {
emailNotificationsRow = <div>
{ _t('Add an email address above to configure email notifications') }
</div>;
} else {
// This only supports the first email address in your profile for now
emailNotificationsRow = this.emailNotificationsRow(
emailThreepids[0].address,
`${_t('Enable email notifications')} (${emailThreepids[0].address})`
);
}
// Build external push rules
const externalRules = [];
for (let i in this.state.externalPushRules) {
const rule = this.state.externalPushRules[i];
externalRules.push(<li>{ _t(rule.description) }</li>);
}
// Show keywords not displayed by the vector UI as a single external push rule
let externalKeywords = [];
for (let i in this.state.externalContentRules) {
const rule = this.state.externalContentRules[i];
externalKeywords.push(rule.pattern);
}
if (externalKeywords.length) {
externalKeywords = externalKeywords.join(", ");
externalRules.push(<li>{ _t('Notifications on the following keywords follow rules which cant be displayed here:') } { externalKeywords }</li>);
}
let devicesSection;
if (this.state.pushers === undefined) {
devicesSection = <div className="error">{ _t('Unable to fetch notification target list') }</div>
} else if (this.state.pushers.length == 0) {
devicesSection = null;
} else {
// TODO: It would be great to be able to delete pushers from here too,
// and this wouldn't be hard to add.
const rows = [];
for (let i = 0; i < this.state.pushers.length; ++i) {
rows.push(<tr key={ i }>
<td>{this.state.pushers[i].app_display_name}</td>
<td>{this.state.pushers[i].device_display_name}</td>
</tr>);
}
devicesSection = (<table className="mx_UserSettings_devicesTable">
<tbody>
{rows}
</tbody>
</table>);
}
if (devicesSection) {
devicesSection = (<div>
<h3>{ _t('Notification targets') }</h3>
{ devicesSection }
</div>);
}
let advancedSettings;
if (externalRules.length) {
advancedSettings = (
<div>
<h3>{ _t('Advanced notification settings') }</h3>
{ _t('There are advanced notifications which are not shown here') }.<br/>
{ _t('You might have configured them in a client other than Riot. You cannot tune them in Riot but they still apply') }.
<ul>
{ externalRules }
</ul>
</div>
);
}
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserSettings_notifTable">
{ spinner }
<div className="mx_UserNotifSettings_tableRow">
<div className="mx_UserNotifSettings_inputCell">
<input id="enableDesktopNotifications"
ref="enableDesktopNotifications"
type="checkbox"
checked={ SettingsStore.getValue("notificationsEnabled") }
onChange={ this.onEnableDesktopNotificationsChange } />
</div>
<div className="mx_UserNotifSettings_labelCell">
<label htmlFor="enableDesktopNotifications">
{ _t('Enable desktop notifications') }
</label>
</div>
</div>
<div className="mx_UserNotifSettings_tableRow">
<div className="mx_UserNotifSettings_inputCell">
<input id="enableDesktopNotificationBody"
ref="enableDesktopNotificationBody"
type="checkbox"
checked={ SettingsStore.getValue("notificationBodyEnabled") }
onChange={ this.onEnableDesktopNotificationBodyChange } />
</div>
<div className="mx_UserNotifSettings_labelCell">
<label htmlFor="enableDesktopNotificationBody">
{ _t('Show message in desktop notification') }
</label>
</div>
</div>
<div className="mx_UserNotifSettings_tableRow">
<div className="mx_UserNotifSettings_inputCell">
<input id="enableDesktopAudioNotifications"
ref="enableDesktopAudioNotifications"
type="checkbox"
checked={ SettingsStore.getValue("audioNotificationsEnabled") }
onChange={ this.onEnableAudioNotificationsChange } />
</div>
<div className="mx_UserNotifSettings_labelCell">
<label htmlFor="enableDesktopAudioNotifications">
{ _t('Enable audible notifications in web client') }
</label>
</div>
</div>
{ emailNotificationsRow }
<div className="mx_UserNotifSettings_pushRulesTableWrapper">
<table className="mx_UserNotifSettings_pushRulesTable">
<thead>
<tr>
<th width="55%"></th>
<th width="15%">{ _t('Off') }</th>
<th width="15%">{ _t('On') }</th>
<th width="15%">{ _t('Noisy') }</th>
</tr>
</thead>
<tbody>
{ this.renderNotifRulesTableRows() }
</tbody>
</table>
</div>
{ advancedSettings }
{ devicesSection }
</div>
</div>
);
}
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,6 +19,7 @@ import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'IncomingCallBox',
@ -26,14 +28,16 @@ module.exports = React.createClass({
incomingCall: PropTypes.object,
},
onAnswerClick: function() {
onAnswerClick: function(e) {
e.stopPropagation();
dis.dispatch({
action: 'answer',
room_id: this.props.incomingCall.roomId,
});
},
onRejectClick: function() {
onRejectClick: function(e) {
e.stopPropagation();
dis.dispatch({
action: 'hangup',
room_id: this.props.incomingCall.roomId,
@ -59,6 +63,7 @@ module.exports = React.createClass({
}
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_IncomingCallBox" id="incomingCallBox">
<img className="mx_IncomingCallBox_chevron" src="img/chevron-left.png" width="9" height="16" />
@ -67,14 +72,14 @@ module.exports = React.createClass({
</div>
<div className="mx_IncomingCallBox_buttons">
<div className="mx_IncomingCallBox_buttons_cell">
<div className="mx_IncomingCallBox_buttons_decline" onClick={this.onRejectClick}>
<AccessibleButton className="mx_IncomingCallBox_buttons_decline" onClick={this.onRejectClick}>
{ _t("Decline") }
</div>
</AccessibleButton>
</div>
<div className="mx_IncomingCallBox_buttons_cell">
<div className="mx_IncomingCallBox_buttons_accept" onClick={this.onAnswerClick}>
<AccessibleButton className="mx_IncomingCallBox_buttons_accept" onClick={this.onAnswerClick}>
{ _t("Accept") }
</div>
</AccessibleButton>
</div>
</div>
</div>