Merge remote-tracking branch 'origin/develop' into dbkr/my_groups

This commit is contained in:
David Baker 2017-06-30 16:15:41 +01:00
commit 8468a118b5
23 changed files with 1956 additions and 1086 deletions

View file

@ -47,13 +47,12 @@ import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore';
var DEBUG = false;
let DEBUG = false;
let debuglog = function() {};
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
debuglog = console.log.bind(console);
}
module.exports = React.createClass({
@ -113,6 +112,7 @@ module.exports = React.createClass({
callState: null,
guestsCanJoin: false,
canPeek: false,
showApps: false,
// error object, as from the matrix client/server API
// If we failed to load information about the room,
@ -236,6 +236,7 @@ module.exports = React.createClass({
if (room) {
this.setState({
unsentMessageError: this._getUnsentMessageError(room),
showApps: this._shouldShowApps(room),
});
this._onRoomLoaded(room);
}
@ -273,6 +274,11 @@ module.exports = React.createClass({
}
},
_shouldShowApps: function(room) {
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets', '');
return appsStateEvents && Object.keys(appsStateEvents.getContent()).length > 0;
},
componentDidMount: function() {
var call = this._getCallForRoom();
var callState = call ? call.call_state : "ended";
@ -453,9 +459,14 @@ module.exports = React.createClass({
this._updateConfCallNotification();
this.setState({
callState: callState
callState: callState,
});
break;
case 'appsDrawer':
this.setState({
showApps: payload.show,
});
break;
}
},
@ -1604,11 +1615,13 @@ module.exports = React.createClass({
var auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize} >
onResize={this.onChildResize}
showApps={this.state.showApps && !this.state.editingRoomSettings} >
{ aux }
</AuxPanel>
);
@ -1621,8 +1634,14 @@ module.exports = React.createClass({
if (canSpeak) {
messageComposer =
<MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>;
room={this.state.room}
onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
tabComplete={this.tabComplete}
opacity={ this.props.opacity }
showApps={ this.state.showApps }
/>;
}
// TODO: Why aren't we storing the term/scope/count in this format

View file

@ -93,6 +93,10 @@ const SETTINGS_LABELS = [
id: 'disableMarkdown',
label: 'Disable markdown formatting',
},
{
id: 'enableSyntaxHighlightLanguageDetection',
label: 'Enable automatic language detection for syntax highlighting',
},
/*
{
id: 'useFixedWidthFont',

View file

@ -0,0 +1,102 @@
/*
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 MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
export default React.createClass({
displayName: 'AppTile',
propTypes: {
id: React.PropTypes.string.isRequired,
url: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
},
getDefaultProps: function() {
return {
url: "",
};
},
_onEditClick: function() {
console.log("Edit widget %s", this.props.id);
},
_onDeleteClick: function() {
console.log("Delete widget %s", this.props.id);
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return;
}
const appsStateEvent = appsStateEvents.getContent();
if (appsStateEvent[this.props.id]) {
delete appsStateEvent[this.props.id];
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
appsStateEvent,
'',
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
});
}
},
formatAppTileName: function() {
let appTileName = "No name";
if(this.props.name && this.props.name.trim()) {
appTileName = this.props.name.trim();
appTileName = appTileName[0].toUpperCase() + appTileName.slice(1).toLowerCase();
}
return appTileName;
},
render: function() {
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div className="mx_AppTileMenuBar">
{this.formatAppTileName()}
<span className="mx_AppTileMenuBarWidgets">
{/* Edit widget */}
{/* <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8" alt="Edit"
onClick={this._onEditClick}
/> */}
{/* Delete widget */}
<img src="img/cancel.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget"
width="8" height="8" alt={_t("Cancel")}
onClick={this._onDeleteClick}
/>
</span>
</div>
<div className="mx_AppTileBody">
<iframe ref="appFrame" src={this.props.url} allowFullScreen="true"></iframe>
</div>
</div>
);
},
});

View file

@ -79,7 +79,7 @@ module.exports = React.createClass({
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) {
} else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else {
return null;

View file

@ -29,6 +29,7 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import UserSettingsStore from "../../../UserSettingsStore";
linkifyMatrix(linkify);
@ -90,7 +91,18 @@ module.exports = React.createClass({
setTimeout(() => {
if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) {
highlight.highlightBlock(blocks[i]);
if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) {
highlight.highlightBlock(blocks[i])
} else {
// Only syntax highlight if there's a class starting with language-
let classes = blocks[i].className.split(/\s+/).filter(function (cl) {
return cl.startsWith('language-');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
}
}, 10);
}

View file

@ -0,0 +1,218 @@
/*
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 MatrixClientPeg from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'AppsDrawer',
propTypes: {
room: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
apps: this._getApps(),
};
},
componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
if (this.state.apps && this.state.apps.length < 1) {
this.onClickAddWidget();
}
// TODO -- Handle Scalar errors
// },
// (err) => {
// this.setState({
// scalar_error: err,
// });
});
}
},
componentWillUnmount: function() {
ScalarMessaging.stopListening();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
},
/**
* Encodes a URI according to a set of template variables. Variables will be
* 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" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
};
if(app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
}
app.id = appId;
app.name = app.name || app.type;
app.url = this.encodeUri(app.url, params);
// switch(app.type) {
// case 'etherpad':
// app.queryParams = '?userName=' + this.props.userId +
// '&padId=' + this.props.room.roomId;
// break;
// case 'jitsi': {
//
// app.queryParams = '?confId=' + app.data.confId +
// '&displayName=' + encodeURIComponent(user.displayName) +
// '&avatarUrl=' + encodeURIComponent(MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl)) +
// '&email=' + encodeURIComponent(this.props.userId) +
// '&isAudioConf=' + app.data.isAudioConf;
//
// break;
// }
// case 'vrdemo':
// app.queryParams = '?roomAlias=' + encodeURIComponent(app.data.roomAlias);
// break;
// }
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
}
this._updateApps();
},
_getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return [];
}
const appsStateEvent = appsStateEvents.getContent();
if (Object.keys(appsStateEvent).length < 1) {
return [];
}
return Object.keys(appsStateEvent).map((appId) => {
return this._initAppConfig(appId, appsStateEvent[appId]);
});
},
_updateApps: function() {
const apps = this._getApps();
if (apps.length < 1) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
}
this.setState({
apps: apps,
});
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) :
null;
Modal.createDialog(IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return <AppTile
key={app.name}
id={app.id}
url={app.url}
name={app.name}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>;
});
const addWidget = this.state.apps && this.state.apps.length < 2 &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
className="mx_AddWidget_button"
title={_t('Add a widget')}>
[+] {_t('Add a widget')}
</div>);
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
{apps}
</div>
{addWidget}
</div>
);
},
});

View file

@ -40,25 +40,51 @@ export default class Autocomplete extends React.Component {
};
}
async componentWillReceiveProps(props, state) {
if (props.query === this.props.query) {
return null;
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
componentWillReceiveProps(newProps, state) {
// Query hasn't changed so don't try to complete it
if (newProps.query === this.props.query) {
return;
}
this.complete(newProps.query, newProps.selection);
}
complete(query, selection) {
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
}
if (query === "") {
this.setState({
// Clear displayed completions
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
// Hide the autocomplete box
hide: true,
});
return Q(null);
}
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
// Don't debounce if we are already showing completions
if (this.state.completions.length > 0) {
autocompleteDelay = 0;
}
const deferred = Q.defer();
this.debounceCompletionsRequest = setTimeout(() => {
getCompletions(
query, selection, this.state.forceComplete,
).then((completions) => {
this.processCompletions(completions);
deferred.resolve();
});
}, autocompleteDelay);
return deferred.promise;
}
processCompletions(completions) {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
@ -88,23 +114,13 @@ export default class Autocomplete extends React.Component {
hide = false;
}
const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200);
// We had no completions before, but do now, so we should apply our display delay here
if (this.state.completionList.length === 0 && completionList.length > 0 &&
!forceComplete && autocompleteDelay > 0) {
await Q.delay(autocompleteDelay);
}
// Force complete is turned off each time since we can't edit the query in that case
forceComplete = false;
this.setState({
completions,
completionList,
selectionOffset,
hide,
forceComplete,
// Force complete is turned off each time since we can't edit the query in that case
forceComplete: false,
});
}

View file

@ -19,7 +19,9 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from '../../../index';
import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils';
import { _t, _tJsx} from '../../../languageHandler';
import AppsDrawer from './AppsDrawer';
import { _t, _tJsx} from '../../../languageHandler';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({
@ -28,6 +30,8 @@ module.exports = React.createClass({
propTypes: {
// js-sdk room object
room: React.PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired,
showApps: React.PropTypes.bool,
// Conference Handler implementation
conferenceHandler: React.PropTypes.object,
@ -70,10 +74,10 @@ module.exports = React.createClass({
},
render: function() {
var CallView = sdk.getComponent("voip.CallView");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileDropTarget = null;
let fileDropTarget = null;
if (this.props.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
@ -87,14 +91,13 @@ module.exports = React.createClass({
);
}
var conferenceCallNotification = null;
let conferenceCallNotification = null;
if (this.props.displayConfCallNotification) {
let supportedText = '';
let joinNode;
if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = _t(" (unsupported)");
}
else {
} else {
joinNode = (<span>
{_tJsx(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
@ -105,7 +108,6 @@ module.exports = React.createClass({
]
)}
</span>);
}
// XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
// but there are translations for this in the languages we do have so I'm leaving it for now.
@ -118,7 +120,7 @@ module.exports = React.createClass({
);
}
var callView = (
const callView = (
<CallView ref="callView" room={this.props.room}
ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onResize}
@ -126,8 +128,17 @@ module.exports = React.createClass({
/>
);
let appsDrawer = null;
if(UserSettingsStore.isFeatureEnabled('matrix_apps') && this.props.showApps) {
appsDrawer = <AppsDrawer ref="appsDrawer"
room={this.props.room}
userId={this.props.userId}
maxHeight={this.props.maxHeight}/>;
}
return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
{ appsDrawer }
{ fileDropTarget }
{ callView }
{ conferenceCallNotification }

View file

@ -13,16 +13,15 @@ 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.
*/
var React = require('react');
import React from 'react';
import { _t } from '../../../languageHandler';
var CallHandler = require('../../../CallHandler');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import Autocomplete from './Autocomplete';
import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore';
@ -32,6 +31,8 @@ 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);
@ -57,7 +58,6 @@ export default class MessageComposer extends React.Component {
},
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
};
}
componentDidMount() {
@ -127,7 +127,7 @@ export default class MessageComposer extends React.Component {
if(shouldUpload) {
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
if (files) {
for(var i=0; i<files.length; i++) {
for(let i=0; i<files.length; i++) {
this.props.uploadFile(files[i]);
}
}
@ -139,7 +139,7 @@ export default class MessageComposer extends React.Component {
}
onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId);
const call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall();
if (!call) {
return;
@ -152,20 +152,68 @@ export default class MessageComposer extends React.Component {
});
}
// _startCallApp(isAudioConf) {
// dis.dispatch({
// action: 'appsDrawer',
// show: true,
// });
// const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
// let appsStateEvent = {};
// if (appsStateEvents) {
// appsStateEvent = appsStateEvents.getContent();
// }
// if (!appsStateEvent.videoConf) {
// appsStateEvent.videoConf = {
// type: 'jitsi',
// // FIXME -- This should not be localhost
// url: 'http://localhost:8000/jitsi.html',
// data: {
// confId: this.props.room.roomId.replace(/[^A-Za-z0-9]/g, '_') + Date.now(),
// isAudioConf: isAudioConf,
// },
// };
// MatrixClientPeg.get().sendStateEvent(
// this.props.room.roomId,
// 'im.vector.modular.widgets',
// appsStateEvent,
// '',
// ).then(() => console.log('Sent state'), (e) => console.error(e));
// }
// }
onCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId,
});
// this._startCallApp(false);
}
onVoiceCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({
action: 'place_call',
type: 'voice',
type: "voice",
room_id: this.props.room.roomId,
});
// 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}) {
@ -216,19 +264,19 @@ export default class MessageComposer extends React.Component {
}
render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
const uploadInputStyle = {display: 'none'};
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
(UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old"));
var controls = [];
const controls = [];
controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberAvatar member={me} width={24} height={24} />
</div>
</div>,
);
let e2eImg, e2eTitle, e2eClass;
@ -247,16 +295,15 @@ export default class MessageComposer extends React.Component {
controls.push(
<img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12"
alt={e2eTitle} title={e2eTitle}
/>
/>,
);
var callButton, videoCallButton, hangupButton;
let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton;
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
<div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src="img/hangup.svg" alt={ _t('Hangup') } title={ _t('Hangup') } width="25" height="26"/>
</div>;
}
else {
} else {
callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={ _t('Voice call') }>
<TintableSvg src="img/icon-call.svg" width="35" height="35"/>
@ -267,14 +314,29 @@ export default class MessageComposer extends React.Component {
</div>;
}
var canSendMessages = this.props.room.currentState.maySendMessage(
// Apps
if (UserSettingsStore.isFeatureEnabled('matrix_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-apps-active.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-apps.svg" width="35" height="35"/>
</div>;
}
}
const canSendMessages = this.props.room.currentState.maySendMessage(
MatrixClientPeg.get().credentials.userId);
if (canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
var uploadButton = (
const uploadButton = (
<div key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={ _t('Upload file') }>
<TintableSvg src="img/icons-upload.svg" width="35" height="35"/>
@ -300,7 +362,7 @@ export default class MessageComposer extends React.Component {
controls.push(
<MessageComposerInput
ref={c => this.messageComposerInput = c}
ref={(c) => this.messageComposerInput = c}
key="controls_input"
onResize={this.props.onResize}
room={this.props.room}
@ -316,13 +378,15 @@ export default class MessageComposer extends React.Component {
uploadButton,
hangupButton,
callButton,
videoCallButton
videoCallButton,
showAppsButton,
hideAppsButton,
);
} else {
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
</div>
</div>,
);
}
@ -340,7 +404,7 @@ export default class MessageComposer extends React.Component {
const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
name => {
(name) => {
const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
@ -403,5 +467,8 @@ MessageComposer.propTypes = {
uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number
opacity: React.PropTypes.number,
// string representing the current room app drawer state
showApps: React.PropTypes.bool,
};

View file

@ -87,6 +87,13 @@ export default class MessageComposerInput extends React.Component {
return 'toggle-mode';
}
// Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I
if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) {
// When null is returned, draft-js will NOT preventDefault, allowing dev tools
// to be toggled when the editor is focussed
return null;
}
return getDefaultKeyBinding(e);
}
@ -114,6 +121,7 @@ export default class MessageComposerInput extends React.Component {
this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
this.onTextPasted = this.onTextPasted.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false);
@ -126,6 +134,10 @@ export default class MessageComposerInput extends React.Component {
// the original editor state, before we started tabbing through completions
originalEditorState: null,
// the virtual state "above" the history stack, the message currently being composed that
// we want to persist whilst browsing history
currentlyComposedEditorState: null,
};
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
@ -217,7 +229,8 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
}
const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.moveSelectionToEnd(editorState);
this.onEditorContentChanged(editorState);
editor.focus();
}
@ -425,6 +438,29 @@ export default class MessageComposerInput extends React.Component {
return false;
}
onTextPasted(text: string, html?: string) {
const currentSelection = this.state.editorState.getSelection();
const currentContent = this.state.editorState.getCurrentContent();
let contentState = null;
if (html) {
contentState = Modifier.replaceWithFragment(
currentContent,
currentSelection,
RichText.htmlToContentState(html).getBlockMap(),
);
} else {
contentState = Modifier.replaceText(currentContent, currentSelection, text);
}
let newEditorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
newEditorState = EditorState.forceSelection(newEditorState, contentState.getSelectionAfter());
this.onEditorContentChanged(newEditorState);
return true;
}
handleReturn(ev) {
if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
@ -476,9 +512,30 @@ export default class MessageComposerInput extends React.Component {
}
if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs(
RichText.contentStateToHTML(contentState),
);
// We should only send HTML if any block is styled or contains inline style
let shouldSendHTML = false;
const blocks = contentState.getBlocksAsArray();
if (blocks.some((block) => block.getType() !== 'unstyled')) {
shouldSendHTML = true;
} else {
const characterLists = blocks.map((block) => block.getCharacterList());
// For each block of characters, determine if any inline styles are applied
// and if yes, send HTML
characterLists.forEach((characters) => {
const numberOfStylesForCharacters = characters.map(
(character) => character.getStyle().toArray().length,
).toArray();
// If any character has more than 0 inline styles applied, send HTML
if (numberOfStylesForCharacters.some((styles) => styles > 0)) {
shouldSendHTML = true;
}
});
}
if (shouldSendHTML) {
contentHTML = HtmlUtils.processHtmlForSending(
RichText.contentStateToHTML(contentState),
);
}
} else {
const md = new Markdown(contentText);
if (md.isPlainText()) {
@ -499,9 +556,15 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage;
}
this.historyManager.addItem(
this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(),
this.state.isRichtextEnabled ? 'html' : 'markdown');
if (this.state.isRichtextEnabled) {
this.historyManager.addItem(
contentHTML ? contentHTML : contentText,
contentHTML ? 'html' : 'markdown',
);
} else {
// Always store MD input as input history
this.historyManager.addItem(contentText, 'markdown');
}
let sendMessagePromise;
if (contentHTML) {
@ -525,49 +588,108 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete.hide();
return true;
}
onUpArrow = (e) => {
this.onVerticalArrow(e, true);
};
onUpArrow = async (e) => {
const completion = this.autocomplete.onUpArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
onDownArrow = (e) => {
this.onVerticalArrow(e, false);
};
onDownArrow = async (e) => {
const completion = this.autocomplete.onDownArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
};
// tab and shift-tab are mapped to down and up arrow respectively
onTab = async (e) => {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
onVerticalArrow = (e, up) => {
// Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) {
await this.autocomplete.forceComplete();
this.onDownArrow(e);
// Don't go back in history if we're in the middle of a multi-line message
const selection = this.state.editorState.getSelection();
const blockKey = selection.getStartKey();
const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock();
const lastBlock = this.state.editorState.getCurrentContent().getLastBlock();
const selectionOffset = selection.getAnchorOffset();
let canMoveUp = false;
let canMoveDown = false;
if (blockKey === firstBlock.getKey()) {
const textBeforeCursor = firstBlock.getText().slice(0, selectionOffset);
canMoveUp = textBeforeCursor.indexOf('\n') === -1;
}
if (blockKey === lastBlock.getKey()) {
const textAfterCursor = lastBlock.getText().slice(selectionOffset);
canMoveDown = textAfterCursor.indexOf('\n') === -1;
}
if ((up && !canMoveUp) || (!up && !canMoveDown)) return;
const selected = this.selectHistory(up);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
}
} else {
await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
this.moveAutocompleteSelection(up);
}
};
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
});
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return;
}
const newContent = this.historyManager.getItem(delta, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
let editorState = EditorState.push(
this.state.editorState,
newContent,
'insert-characters',
);
// Move selection to the end of the selected history
let newSelection = SelectionState.createEmpty(newContent.getLastBlock().getKey());
newSelection = newSelection.merge({
focusOffset: newContent.getLastBlock().getLength(),
anchorOffset: newContent.getLastBlock().getLength(),
});
editorState = EditorState.forceSelection(editorState, newSelection);
this.setState({editorState});
return true;
};
onTab = async (e) => {
e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) {
// Force completions to show for the text currently entered
await this.autocomplete.forceComplete();
// Select the first item by moving "down"
await this.moveAutocompleteSelection(false);
} else {
await this.moveAutocompleteSelection(e.shiftKey);
}
};
moveAutocompleteSelection = (up) => {
const completion = up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
return this.setDisplayedCompletion(completion);
};
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
@ -706,6 +828,7 @@ export default class MessageComposerInput extends React.Component {
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
handlePastedText={this.onTextPasted}
handlePastedFiles={this.props.onFilesPasted}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}