Merge branch 'develop' into matthew/notif-panel

This commit is contained in:
Matthew Hodgson 2016-09-09 11:12:52 +01:00
commit 904348e62a
14 changed files with 898 additions and 149 deletions

View file

@ -26,10 +26,9 @@
"dependencies": {
"browser-request": "^0.3.3",
"classnames": "^2.1.2",
"draft-js": "^0.7.0",
"draft-js-export-html": "^0.2.2",
"draft-js": "^0.8.1",
"draft-js-export-html": "^0.4.0",
"draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6",
"emojione": "2.2.3",
"favico.js": "^0.3.10",
"filesize": "^3.1.2",

View file

@ -14,64 +14,22 @@ import {
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: 'span',
/*
draft uses <div> by default which we don't really like, so we're using <span>
this is probably not a good idea since <span> is not a block level element but
we're trying to fix things in contentStateToHTML below
*/
});
const STYLES = {
BOLD: 'strong',
CODE: 'code',
ITALIC: 'em',
STRIKETHROUGH: 's',
UNDERLINE: 'u',
};
import {stateToHTML} from 'draft-js-export-html';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
CODE: /`[^`]*`/g,
STRIKETHROUGH: /~{2}[^~]*~{2}/g,
};
const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string {
return contentState.getBlockMap().map((block) => {
let elem = BLOCK_RENDER_MAP.get(block.getType()).element;
let content = [];
block.findStyleRanges(
() => true, // always return true => don't filter any ranges out
(start, end) => {
// map style names to elements
let tags = block.getInlineStyleAt(start).map(style => STYLES[style]).filter(style => !!style);
// combine them to get well-nested HTML
let open = tags.map(tag => `<${tag}>`).join('');
let close = tags.map(tag => `</${tag}>`).reverse().join('');
// and get the HTML representation of this styled range (this .substring() should never fail)
let text = block.getText().substring(start, end);
// http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
let div = document.createElement('div');
div.appendChild(document.createTextNode(text));
let safeText = div.innerHTML;
content.push(`${open}${safeText}${close}`);
}
);
let result = `<${elem}>${content.join('')}</${elem}>`;
// dirty hack because we don't want block level tags by default, but breaks
if (elem === 'span')
result += '<br />';
return result;
}).join('');
}
export const contentStateToHTML = stateToHTML;
export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
@ -98,6 +56,19 @@ function unicodeToEmojiUri(str) {
return str;
}
/**
* Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
* From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
*/
function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
@ -151,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['BOLD', 'ITALIC'].map(
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
@ -178,19 +149,6 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
return markdownDecorators;
}
/**
* Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
* From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
*/
function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
/**
* Passes rangeToReplace to modifyFn and replaces it in contentState with the result.
*/

View file

@ -14,6 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
/**
* Given a room object, return the alias we should use for it,
@ -75,3 +78,57 @@ export function looksLikeDirectMessageRoom(room, me) {
}
return false;
}
/**
* Marks or unmarks the given room as being as a DM room.
* @param {string} roomId The ID of the room to modify
* @param {string} userId The user ID of the desired DM
room target user or null to un-mark
this room as a DM room
* @returns {object} A promise
*/
export function setDMRoom(roomId, userId) {
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
let dmRoomMap = {};
if (mDirectEvent !== undefined) dmRoomMap = mDirectEvent.getContent();
for (const thisUserId of Object.keys(dmRoomMap)) {
const roomList = dmRoomMap[thisUserId];
if (thisUserId == userId) {
if (roomList.indexOf(roomId) == -1) {
roomList.push(roomId);
}
} else {
const indexOfRoom = roomList.indexOf(roomId);
if (indexOfRoom > -1) {
roomList.splice(indexOfRoom, 1);
}
}
}
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
}
/**
* Given a room, estimate which of its members is likely to
* be the target if the room were a DM room and return that user.
*/
export function guessDMRoomTarget(room, me) {
let oldestTs;
let oldestUser;
// Pick the user who's been here longest (and isn't us)
for (const user of room.currentState.getMembers()) {
if (user.userId == me.userId) continue;
if (oldestTs === undefined || user.events.member.getTs() < oldestTs) {
oldestUser = user;
oldestTs = user.events.member.getTs();
}
}
if (oldestUser === undefined) return me;
return oldestUser;
}

View file

@ -123,6 +123,8 @@ Example:
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require("./MatrixClientPeg");
const MatrixEvent = require("matrix-js-sdk").MatrixEvent;
const dis = require("./dispatcher");
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
@ -188,17 +190,52 @@ function setBotOptions(event, roomId, userId) {
});
}
function setBotPower(event, roomId, userId, level) {
if (!(Number.isInteger(level) && level >= 0)) {
sendError(event, "Power level must be positive integer.");
return;
}
console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
return;
}
client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => {
let powerEvent = new MatrixEvent(
{
type: "m.room.power_levels",
content: powerLevels,
}
);
client.setPowerLevel(roomId, userId, level, powerEvent).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : "Failed to send request.", err);
});
});
}
function getMembershipState(event, roomId, userId) {
console.log(`membership_state of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.member", userId);
}
function getJoinRules(event, roomId) {
console.log(`join_rules of ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.join_rules", "");
}
function botOptions(event, roomId, userId) {
console.log(`bot_options of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
}
function returnStateEvent(event, roomId, eventType, stateKey) {
const client = MatrixClientPeg.get();
if (!client) {
@ -218,6 +255,17 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
sendResponse(event, stateEvent.getContent());
}
var currentRoomId = null;
// Listen for when a room is viewed
dis.register(onAction);
function onAction(payload) {
if (payload.action !== "view_room") {
return;
}
currentRoomId = payload.room_id;
}
const onMessage = function(event) {
if (!event.origin) { // stupid chrome
event.origin = event.originalEvent.origin;
@ -235,14 +283,29 @@ const onMessage = function(event) {
const roomId = event.data.room_id;
const userId = event.data.user_id;
if (!userId) {
sendError(event, "Missing user_id in request");
return;
}
if (!roomId) {
sendError(event, "Missing room_id in request");
return;
}
if (!currentRoomId) {
sendError(event, "Must be viewing a room");
return;
}
if (roomId !== currentRoomId) {
sendError(event, "Room " + roomId + " not visible");
return;
}
// Getting join rules does not require userId
if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId);
return;
}
if (!userId) {
sendError(event, "Missing user_id in request");
return;
}
switch (event.data.action) {
case "membership_state":
getMembershipState(event, roomId, userId);
@ -256,6 +319,9 @@ const onMessage = function(event) {
case "set_bot_options":
setBotOptions(event, roomId, userId);
break;
case "set_bot_power":
setBotPower(event, roomId, userId, event.data.level);
break;
default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
break;

View file

@ -130,9 +130,9 @@ module.exports = {
return event ? event.getContent() : {};
},
getSyncedSetting: function(type) {
getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings();
return settings[type];
return settings.hasOwnProperty(type) ? settings[type] : null;
},
setSyncedSetting: function(type, value) {

View file

@ -47,6 +47,7 @@ module.exports.components['views.avatars.RoomAvatar'] = require('./components/vi
module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton');
module.exports.components['views.create_room.Presets'] = require('./components/views/create_room/Presets');
module.exports.components['views.create_room.RoomAlias'] = require('./components/views/create_room/RoomAlias');
module.exports.components['views.dialogs.ChatInviteDialog'] = require('./components/views/dialogs/ChatInviteDialog');
module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog');
module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog');
module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt');
@ -55,6 +56,7 @@ module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./com
module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog');
module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog');
module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog');
module.exports.components['views.elements.AddressTile'] = require('./components/views/elements/AddressTile');
module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText');
module.exports.components['views.elements.EditableTextContainer'] = require('./components/views/elements/EditableTextContainer');
module.exports.components['views.elements.EmojiText'] = require('./components/views/elements/EmojiText');

View file

@ -370,6 +370,9 @@ module.exports = React.createClass({
this._setPage(this.PageTypes.RoomDirectory);
this.notifyNewScreen('directory');
break;
case 'view_create_chat':
this._createChat();
break;
case 'notifier_enabled':
this.forceUpdate();
break;
@ -506,6 +509,13 @@ module.exports = React.createClass({
}
},
_createChat: function() {
var ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog");
Modal.createDialog(ChatInviteDialog, {
title: "Start a one to one chat",
});
},
// update scrollStateMap according to the current scroll state of the
// room view.
_updateScrollMap: function() {

View file

@ -0,0 +1,371 @@
/*
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.
*/
var React = require("react");
var classNames = require('classnames');
var sdk = require("../../../index");
var Invite = require("../../../Invite");
var createRoom = require("../../../createRoom");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var DMRoomMap = require('../../../utils/DMRoomMap');
var rate_limited_func = require("../../../ratelimitedfunc");
var dis = require("../../../dispatcher");
var Modal = require('../../../Modal');
const TRUNCATE_QUERY_LIST = 40;
module.exports = React.createClass({
displayName: "ChatInviteDialog",
propTypes: {
title: React.PropTypes.string,
description: React.PropTypes.oneOfType([
React.PropTypes.element,
React.PropTypes.string,
]),
value: React.PropTypes.string,
placeholder: React.PropTypes.string,
button: React.PropTypes.string,
focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired
},
getDefaultProps: function() {
return {
title: "Start a chat",
description: "Who would you like to communicate with?",
value: "",
placeholder: "User ID, Name or email",
button: "Start Chat",
focus: true
};
},
getInitialState: function() {
return {
user: null,
queryList: [],
addressSelected: false,
selected: 0,
hover: false,
};
},
componentDidMount: function() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this.refs.textinput.value = this.props.value;
}
this._updateUserList();
},
componentDidUpdate: function() {
// As the user scrolls with the arrow keys keep the selected item
// at the top of the window.
if (this.scrollElement && !this.state.hover) {
var elementHeight = this.queryListElement.getBoundingClientRect().height;
this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight;
}
},
onStartChat: function() {
var addr;
// Either an address tile was created, or text input is being used
if (this.state.user) {
addr = this.state.user.userId;
} else {
addr = this.refs.textinput.value;
}
// Check if the addr is a valid type
if (Invite.getAddressType(addr) === "mx") {
var room = this._getDirectMessageRoom(addr);
if (room) {
// A Direct Message room already exists for this user and you
// so go straight to that room
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
this.props.onFinished(true, addr);
} else {
this._startChat(addr);
}
} else if (Invite.getAddressType(addr) === "email") {
this._startChat(addr);
} else {
// Nothing to do, so focus back on the textinput
this.refs.textinput.focus();
}
},
onCancel: function() {
this.props.onFinished(false);
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
} else if (e.keyCode === 38) { // up arrow
e.stopPropagation();
e.preventDefault();
if (this.state.selected > 0) {
this.setState({
selected: this.state.selected - 1,
hover : false,
});
}
} else if (e.keyCode === 40) { // down arrow
e.stopPropagation();
e.preventDefault();
if (this.state.selected < this._maxSelected(this.state.queryList)) {
this.setState({
selected: this.state.selected + 1,
hover : false,
});
}
} else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
if (this.state.queryList.length > 0) {
this.setState({
user: this.state.queryList[this.state.selected],
addressSelected: true,
queryList: [],
hover : false,
});
}
}
},
onQueryChanged: function(ev) {
var query = ev.target.value;
var queryList = [];
// Only do search if there is something to search
if (query.length > 0) {
queryList = this._userList.filter((user) => {
return this._matches(query, user);
});
}
// Make sure the selected item isn't outside the list bounds
var selected = this.state.selected;
var maxSelected = this._maxSelected(queryList);
if (selected > maxSelected) {
selected = maxSelected;
}
this.setState({
queryList: queryList,
selected: selected,
});
},
onDismissed: function() {
this.setState({
user: null,
addressSelected: false,
selected: 0,
queryList: [],
});
},
onClick: function(index) {
var self = this;
return function() {
self.setState({
user: self.state.queryList[index],
addressSelected: true,
queryList: [],
hover: false,
});
};
},
onMouseEnter: function(index) {
var self = this;
return function() {
self.setState({
selected: index,
hover: true,
});
};
},
onMouseLeave: function() {
this.setState({ hover : false });
},
createQueryListTiles: function() {
var self = this;
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var AddressTile = sdk.getComponent("elements.AddressTile");
var maxSelected = this._maxSelected(this.state.queryList);
var queryList = [];
// Only create the query elements if there are queries
if (this.state.queryList.length > 0) {
for (var i = 0; i <= maxSelected; i++) {
var classes = classNames({
"mx_ChatInviteDialog_queryListElement": true,
"mx_ChatInviteDialog_selected": this.state.selected === i,
});
// NOTE: Defaulting to "vector" as the network, until the network backend stuff is done.
// Saving the queryListElement so we can use it to work out, in the componentDidUpdate
// method, how far to scroll when using the arrow keys
queryList.push(
<div className={classes} onClick={this.onClick(i)} onMouseEnter={this.onMouseEnter(i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.queryListElement = ref; }} >
<AddressTile user={this.state.queryList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
</div>
);
}
}
return queryList;
},
_getDirectMessageRoom: function(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
var dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
if (dmRooms.length > 0) {
// Cycle through all the DM rooms and find the first non forgotten or parted room
for (let i = 0; i < dmRooms.length; i++) {
let room = MatrixClientPeg.get().getRoom(dmRooms[i]);
if (room) {
return room;
}
}
}
return null;
},
_startChat: function(addr) {
// Start the chat
createRoom().then(function(roomId) {
return Invite.inviteToRoom(roomId, addr);
})
.catch(function(err) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failure to invite user",
description: err.toString()
});
return null;
})
.done();
// Close - this will happen before the above, as that is async
this.props.onFinished(true, addr);
},
_updateUserList: new rate_limited_func(function() {
// Get all the users
this._userList = MatrixClientPeg.get().getUsers();
}, 500),
_maxSelected: function(list) {
var listSize = list.length === 0 ? 0 : list.length - 1;
var maxSelected = listSize > (TRUNCATE_QUERY_LIST - 1) ? (TRUNCATE_QUERY_LIST - 1) : listSize
return maxSelected;
},
// This is the search algorithm for matching users
_matches: function(query, user) {
var name = user.displayName.toLowerCase();
var uid = user.userId.toLowerCase();
query = query.toLowerCase();
// direct prefix matches
if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) {
return true;
}
// strip @ on uid and try matching again
if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) {
return true;
}
// split spaces in name and try matching constituent parts
var parts = name.split(" ");
for (var i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) {
return true;
}
}
return false;
},
render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
this.scrollElement = null;
var query;
if (this.state.addressSelected) {
var AddressTile = sdk.getComponent("elements.AddressTile");
query = (
<AddressTile user={this.state.user} canDismiss={true} onDismissed={this.onDismissed} />
);
} else {
query = (
<textarea rows="1"
id="textinput"
ref="textinput"
className="mx_ChatInviteDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>
);
}
var queryList;
var queryListElements = this.createQueryListTiles();
if (queryListElements.length > 0) {
queryList = (
<div className="mx_ChatInviteDialog_queryList" ref={(ref) => {this.scrollElement = ref}}>
{ queryListElements }
</div>
);
}
return (
<div className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_ChatInviteDialog_cancel" onClick={this.onCancel} >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</div>
<div className="mx_ChatInviteDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
<div className="mx_Dialog_content">
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
{ queryList }
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onStartChat}>
{this.props.button}
</button>
</div>
</div>
);
}
});

View file

@ -0,0 +1,93 @@
/*
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 classNames = require('classnames');
var sdk = require("../../../index");
var Avatar = require('../../../Avatar');
module.exports = React.createClass({
displayName: 'AddressTile',
propTypes: {
user: React.PropTypes.object.isRequired,
canDismiss: React.PropTypes.bool,
onDismissed: React.PropTypes.func,
justified: React.PropTypes.bool,
networkName: React.PropTypes.string,
networkUrl: React.PropTypes.string,
},
getDefaultProps: function() {
return {
canDismiss: false,
onDismissed: function() {}, // NOP
justified: false,
networkName: "",
networkUrl: "",
};
},
render: function() {
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var userId = this.props.user.userId;
var name = this.props.user.displayName || userId;
var imgUrl = Avatar.avatarUrlForUser(this.props.user, 25, 25, "crop");
var network;
if (this.props.networkUrl !== "") {
network = (
<div className="mx_AddressTile_network">
<BaseAvatar width={25} height={25} name={this.props.networkName} title="vector" url={this.props.networkUrl} />
</div>
);
}
var dismiss;
if (this.props.canDismiss) {
dismiss = (
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
<TintableSvg src="img/icon-address-delete.svg" width="9" height="9" />
</div>
);
}
var nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
var idClasses = classNames({
"mx_AddressTile_id": true,
"mx_AddressTile_justified": this.props.justified,
});
return (
<div className="mx_AddressTile">
{ network }
<div className="mx_AddressTile_avatar">
<BaseAvatar width={25} height={25} name={name} title={name} url={imgUrl} />
</div>
<div className={nameClasses}>{ name }</div>
<div className={idClasses}>{ userId }</div>
{ dismiss }
</div>
);
}
});

View file

@ -149,13 +149,13 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)}
</div>
) : null;
});
}).filter(completion => !!completion);
return (
return renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
{renderedCompletions}
</div>
);
) : null;
}
}

View file

@ -21,6 +21,7 @@ var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore';
@ -38,10 +39,20 @@ export default class MessageComposer extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this);
this._tryComplete = this._tryComplete.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.state = {
autocompleteQuery: '',
selection: null,
inputState: {
style: [],
blockType: null,
isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true),
wordCount: 0,
},
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
};
}
@ -134,6 +145,10 @@ export default class MessageComposer extends React.Component {
});
}
onInputStateChanged(inputState) {
this.setState({inputState});
}
onUpArrow() {
return this.refs.autocomplete.onUpArrow();
}
@ -155,6 +170,21 @@ export default class MessageComposer extends React.Component {
}
}
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) {
event.preventDefault();
this.messageComposerInput.onFormatButtonClicked(name, event);
}
onToggleFormattingClicked() {
UserSettingsStore.setSyncedSetting('MessageComposer.showFormatting', !this.state.showFormatting);
this.setState({showFormatting: !this.state.showFormatting});
}
onToggleMarkdownClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled);
}
render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'};
@ -207,6 +237,16 @@ export default class MessageComposer extends React.Component {
</div>
);
const formattingButton = (
<img className="mx_MessageComposer_formatting"
title="Show Text Formatting Toolbar"
src="img/button-text-formatting.svg"
onClick={this.onToggleFormattingClicked}
style={{visibility: this.state.showFormatting ||
!UserSettingsStore.isFeatureEnabled('rich_text_editor') ? 'hidden' : 'visible'}}
key="controls_formatting" />
);
controls.push(
<MessageComposerInput
ref={c => this.messageComposerInput = c}
@ -217,7 +257,9 @@ export default class MessageComposer extends React.Component {
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} />,
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
uploadButton,
hangupButton,
callButton,
@ -242,6 +284,26 @@ export default class MessageComposer extends React.Component {
</div>;
}
const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
name => {
const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name;
const className = classNames("mx_MessageComposer_format_button", {
mx_MessageComposer_format_button_disabled: disabled,
});
return <img className={className}
title={name}
onMouseDown={disabled ? null : onFormatButtonClicked}
key={name}
src={`img/button-text-${name}${suffix}.svg`}
height="17" />;
},
);
return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
{autoComplete}
@ -250,6 +312,22 @@ export default class MessageComposer extends React.Component {
{controls}
</div>
</div>
{UserSettingsStore.isFeatureEnabled('rich_text_editor') ?
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar" style={this.state.showFormatting ? {} : {display: 'none'}}>
{formatButtons}
<div style={{flex: 1}}></div>
<img title={`Turn Markdown ${this.state.inputState.isRichtextEnabled ? 'on' : 'off'}`}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title="Hide Text Formatting Toolbar"
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel"
src="img/icon-text-cancel.svg" />
</div>
</div>: null
}
</div>
);
}

View file

@ -29,9 +29,11 @@ marked.setOptions({
import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
import escape from 'lodash/escape';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -41,6 +43,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode';
import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText';
@ -80,7 +83,6 @@ export default class MessageComposerInput extends React.Component {
constructor(props, context) {
super(props, context);
this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.setEditorState = this.setEditorState.bind(this);
@ -88,15 +90,12 @@ export default class MessageComposerInput extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if (isRichtextEnabled == null) {
isRichtextEnabled = 'true';
}
isRichtextEnabled = isRichtextEnabled === 'true';
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
this.state = {
isRichtextEnabled: isRichtextEnabled,
isRichtextEnabled,
editorState: null,
};
@ -236,8 +235,18 @@ export default class MessageComposerInput extends React.Component {
this.sentHistory.saveLastTextEntry();
}
componentWillUpdate(nextProps, nextState) {
// this is dirty, but moving all this state to MessageComposer is dirtier
if (this.props.onInputStateChanged && nextState !== this.state) {
const state = this.getSelectionInfo(nextState.editorState);
state.isRichtextEnabled = nextState.isRichtextEnabled;
this.props.onInputStateChanged(state);
}
}
onAction(payload) {
let editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'focus_composer':
@ -246,35 +255,44 @@ export default class MessageComposerInput extends React.Component {
// TODO change this so we insert a complete user alias
case 'insert_displayname':
if (this.state.editorState.getCurrentContent().hasText()) {
console.log(payload);
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
case 'insert_displayname': {
contentState = Modifier.replaceText(
contentState,
this.state.editorState.getSelection(),
payload.displayname
`${payload.displayname}: `
);
this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
});
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.setEditorState(editorState);
editor.focus();
}
break;
}
case 'quote': {
let {event: {content: {body, formatted_body}}} = payload.event || {};
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(stateToMarkdown(content));
}
onKeyDown(ev) {
if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode;
// set a callback so we can see if the cursor position changes as
// a result of this event. If it doesn't, we cycle history.
setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
const blockMap = content.getBlockMap();
let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
contentState = Modifier.splitBlock(contentState, startSelection);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
contentState = Modifier.replaceWithFragment(contentState,
startSelection,
blockMap);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
if (this.state.isRichtextEnabled)
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.setEditorState(editorState);
editor.focus();
}
}, 0);
}
break;
}
}
@ -344,13 +362,10 @@ export default class MessageComposerInput extends React.Component {
}
}
onInputClick(ev) {
this.refs.editor.focus();
}
setEditorState(editorState: EditorState) {
setEditorState(editorState: EditorState, cb = () => null) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState});
this.setState({editorState}, cb);
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
@ -359,27 +374,34 @@ export default class MessageComposerInput extends React.Component {
}
if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray()));
const textContent = editorState.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection);
}
}
enableRichtext(enabled: boolean) {
let contentState = null;
if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
contentState = RichText.HTMLtoContentState(html);
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
if (markdown[markdown.length - 1] === '\n') {
markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?)
}
contentState = ContentState.createFromText(markdown);
this.setEditorState(this.createEditorState(enabled, contentState));
}
window.localStorage.setItem('mx_editor_rte_enabled', enabled);
this.setEditorState(this.createEditorState(enabled, contentState), () => {
this.setState({
isRichtextEnabled: enabled
isRichtextEnabled: enabled,
});
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
}
handleKeyCommand(command: string): boolean {
@ -391,7 +413,17 @@ export default class MessageComposerInput extends React.Component {
let newState: ?EditorState = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if (!this.state.isRichtextEnabled) {
if (this.state.isRichtextEnabled) {
// These are block types, not handled by RichUtils by default.
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
if (blockCommands.includes(command)) {
this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command));
} else if (command === 'strike') {
// this is the only inline style not handled by Draft by default
this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'));
}
} else {
let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection();
@ -399,7 +431,11 @@ export default class MessageComposerInput extends React.Component {
bold: text => `**${text}**`,
italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
strike: text => `~~${text}~~`,
code: text => `\`${text}\``,
blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
}[command];
if (modifyFn) {
@ -418,12 +454,14 @@ export default class MessageComposerInput extends React.Component {
this.setEditorState(newState);
return true;
}
return false;
}
handleReturn(ev) {
if (ev.shiftKey) {
return false;
this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
return true;
}
const contentState = this.state.editorState.getCurrentContent();
@ -536,20 +574,91 @@ export default class MessageComposerInput extends React.Component {
setTimeout(() => this.refs.editor.focus(), 50);
}
render() {
let className = "mx_MessageComposer_input";
if (this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
e.preventDefault(); // don't steal focus from the editor!
const command = {
code: 'code-block',
quote: 'blockquote',
bullet: 'unordered-list-item',
numbullet: 'ordered-list-item',
}[name] || name;
this.handleKeyCommand(command);
}
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */
getSelectionInfo(editorState: EditorState) {
const styleName = {
BOLD: 'bold',
ITALIC: 'italic',
STRIKETHROUGH: 'strike',
UNDERLINE: 'underline',
};
const originalStyle = editorState.getCurrentInlineStyle().toArray();
const style = originalStyle
.map(style => styleName[style] || null)
.filter(styleName => !!styleName);
const blockName = {
'code-block': 'code',
blockquote: 'quote',
'unordered-list-item': 'bullet',
'ordered-list-item': 'numbullet',
};
const originalBlockType = editorState.getCurrentContent()
.getBlockForKey(editorState.getSelection().getStartKey())
.getType();
const blockType = blockName[originalBlockType] || null;
return {
style,
blockType,
};
}
onMarkdownToggleClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode');
}
getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
render() {
const {editorState} = this.state;
// From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
// If the user changes block type before entering any text, we can
// either style the placeholder or hide it.
let hidePlaceholder = false;
const contentState = editorState.getCurrentContent();
if (!contentState.hasText()) {
if (contentState.getBlockMap().first().getType() !== 'unstyled') {
hidePlaceholder = true;
}
}
const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder,
});
return (
<div className={className}
onClick={ this.onInputClick }>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator"
onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
<Editor ref="editor"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.setEditorState}
blockStyleFn={this.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
@ -582,4 +691,6 @@ MessageComposerInput.propTypes = {
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func.isRequired,
};

View file

@ -58,6 +58,7 @@ module.exports = React.createClass({
cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents);
cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("accountData", this.onAccountData);
var s = this.getRoomLists();
this.setState(s);
@ -78,8 +79,6 @@ module.exports = React.createClass({
switch (payload.action) {
case 'view_tooltip':
this.tooltip = payload.tooltip;
this._repositionTooltip();
if (this.tooltip) this.tooltip.style.display = 'block';
break;
case 'call_state':
var call = CallHandler.getCall(payload.room_id);
@ -109,6 +108,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
}
// cancel any pending calls to the rate_limited_funcs
this._delayedRefreshRoomList.cancelPendingCall();
@ -182,6 +182,12 @@ module.exports = React.createClass({
this._delayedRefreshRoomList();
},
onAccountData: function(ev) {
if (ev.getType() == 'm.direct') {
this._delayedRefreshRoomList();
}
},
_delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList();
}, 500),
@ -309,17 +315,15 @@ module.exports = React.createClass({
},
_whenScrolling: function(e) {
this._repositionTooltip(e);
this._hideTooltip(e);
this._repositionIncomingCallBox(e, false);
this._updateStickyHeaders(false);
},
_repositionTooltip: function(e) {
// We access the parent of the parent, as the tooltip is inside a container
// Needs refactoring into a better multipurpose tooltip
if (this.tooltip && this.tooltip.parentElement && this.tooltip.parentElement.parentElement) {
var scroll = ReactDOM.findDOMNode(this);
this.tooltip.style.top = (3 + scroll.parentElement.offsetTop + this.tooltip.parentElement.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px";
_hideTooltip: function(e) {
// Hide tooltip when scrolling, as we'll no longer be over the one we were on
if (this.tooltip && this.tooltip.style.display !== "none") {
this.tooltip.style.display = "none";
}
},
@ -368,7 +372,7 @@ module.exports = React.createClass({
var scrollArea = this._getScrollNode();
// Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaOffset = scrollArea.getBoundingClientRect().top;
var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the componet from the window
// as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDOM = require("react-dom");
var classNames = require('classnames');
var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg');
@ -245,10 +246,9 @@ module.exports = React.createClass({
} else {
label = <EmojiText element="div" title={ name } className={ nameClasses }>{name}</EmojiText>;
}
}
else if (this.state.hover) {
} else if (this.state.hover) {
var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
label = <RoomTooltip room={this.props.room}/>;
tooltip = <RoomTooltip className="mx_RoomTile_tooltip" room={this.props.room} />;
}
var incomingCallBox;