Merge branch 'develop' into feature-autocomplete
This commit is contained in:
commit
769b3f0c2a
12 changed files with 1075 additions and 273 deletions
|
@ -23,6 +23,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"classnames": "^2.1.2",
|
"classnames": "^2.1.2",
|
||||||
|
"draft-js": "^0.7.0",
|
||||||
|
"draft-js-export-html": "^0.2.2",
|
||||||
|
"draft-js-export-markdown": "^0.2.0",
|
||||||
|
"draft-js-import-markdown": "^0.1.6",
|
||||||
"favico.js": "^0.3.10",
|
"favico.js": "^0.3.10",
|
||||||
"filesize": "^3.1.2",
|
"filesize": "^3.1.2",
|
||||||
"flux": "^2.0.3",
|
"flux": "^2.0.3",
|
||||||
|
|
|
@ -50,6 +50,11 @@ function createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
matrixClient = Matrix.createClient(opts);
|
matrixClient = Matrix.createClient(opts);
|
||||||
|
|
||||||
|
// we're going to add eventlisteners for each matrix event tile, so the
|
||||||
|
// potential number of event listeners is quite high.
|
||||||
|
matrixClient.setMaxListeners(500);
|
||||||
|
|
||||||
if (guestAccess) {
|
if (guestAccess) {
|
||||||
console.log("Guest: %s", guestAccess.isGuest());
|
console.log("Guest: %s", guestAccess.isGuest());
|
||||||
matrixClient.setGuest(guestAccess.isGuest());
|
matrixClient.setGuest(guestAccess.isGuest());
|
||||||
|
@ -91,7 +96,7 @@ class MatrixClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME, XXX: this all seems very convoluted :(
|
// FIXME, XXX: this all seems very convoluted :(
|
||||||
//
|
//
|
||||||
// if we replace the singleton using URLs we bypass our createClientForPeg()
|
// if we replace the singleton using URLs we bypass our createClientForPeg()
|
||||||
// global helper function... but if we replace it using
|
// global helper function... but if we replace it using
|
||||||
// an access_token we don't?
|
// an access_token we don't?
|
||||||
|
|
170
src/RichText.js
Normal file
170
src/RichText.js
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
Modifier,
|
||||||
|
ContentState,
|
||||||
|
convertFromHTML,
|
||||||
|
DefaultDraftBlockRenderMap,
|
||||||
|
DefaultDraftInlineStyle,
|
||||||
|
CompositeDecorator
|
||||||
|
} from 'draft-js';
|
||||||
|
import * as sdk from './index';
|
||||||
|
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARKDOWN_REGEX = {
|
||||||
|
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
|
||||||
|
ITALIC: /([\*_])([\w\s]+?)\1/g,
|
||||||
|
BOLD: /([\*_])\1([\w\s]+?)\1\1/g
|
||||||
|
};
|
||||||
|
|
||||||
|
const USERNAME_REGEX = /@\S+:\S+/g;
|
||||||
|
const ROOM_REGEX = /#\S+:\S+/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 function HTMLtoContentState(html: string): ContentState {
|
||||||
|
return ContentState.createFromBlockArray(convertFromHTML(html));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a composite decorator which has access to provided scope.
|
||||||
|
*/
|
||||||
|
export function getScopedRTDecorators(scope: any): CompositeDecorator {
|
||||||
|
let MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||||
|
|
||||||
|
let usernameDecorator = {
|
||||||
|
strategy: (contentBlock, callback) => {
|
||||||
|
findWithRegex(USERNAME_REGEX, contentBlock, callback);
|
||||||
|
},
|
||||||
|
component: (props) => {
|
||||||
|
let member = scope.room.getMember(props.children[0].props.text);
|
||||||
|
// unused until we make these decorators immutable (autocomplete needed)
|
||||||
|
let name = member ? member.name : null;
|
||||||
|
let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
|
||||||
|
return <span className="mx_UserPill">{avatar} {props.children}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let roomDecorator = {
|
||||||
|
strategy: (contentBlock, callback) => {
|
||||||
|
findWithRegex(ROOM_REGEX, contentBlock, callback);
|
||||||
|
},
|
||||||
|
component: (props) => {
|
||||||
|
return <span className="mx_RoomPill">{props.children}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [usernameDecorator, roomDecorator];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScopedMDDecorators(scope: any): CompositeDecorator {
|
||||||
|
let markdownDecorators = ['BOLD', 'ITALIC'].map(
|
||||||
|
(style) => ({
|
||||||
|
strategy: (contentBlock, callback) => {
|
||||||
|
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
|
||||||
|
},
|
||||||
|
component: (props) => (
|
||||||
|
<span className={"mx_MarkdownElement mx_Markdown_" + style}>
|
||||||
|
{props.children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
markdownDecorators.push({
|
||||||
|
strategy: (contentBlock, callback) => {
|
||||||
|
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
|
||||||
|
},
|
||||||
|
component: (props) => (
|
||||||
|
<a href="#" className="mx_MarkdownElement mx_Markdown_LINK">
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function modifyText(contentState: ContentState, rangeToReplace: SelectionState,
|
||||||
|
modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState {
|
||||||
|
let getText = (key) => contentState.getBlockForKey(key).getText(),
|
||||||
|
startKey = rangeToReplace.getStartKey(),
|
||||||
|
startOffset = rangeToReplace.getStartOffset(),
|
||||||
|
endKey = rangeToReplace.getEndKey(),
|
||||||
|
endOffset = rangeToReplace.getEndOffset(),
|
||||||
|
text = "";
|
||||||
|
|
||||||
|
|
||||||
|
for(let currentKey = startKey;
|
||||||
|
currentKey && currentKey !== endKey;
|
||||||
|
currentKey = contentState.getKeyAfter(currentKey)) {
|
||||||
|
let blockText = getText(currentKey);
|
||||||
|
text += blockText.substring(startOffset, blockText.length);
|
||||||
|
|
||||||
|
// from now on, we'll take whole blocks
|
||||||
|
startOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add remaining part of last block
|
||||||
|
text += getText(endKey).substring(startOffset, endOffset);
|
||||||
|
|
||||||
|
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey);
|
||||||
|
}
|
|
@ -112,4 +112,12 @@ module.exports = {
|
||||||
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
|
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isFeatureEnabled: function(feature: string): boolean {
|
||||||
|
return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true';
|
||||||
|
},
|
||||||
|
|
||||||
|
setFeatureEnabled: function(feature: string, enabled: boolean) {
|
||||||
|
localStorage.setItem(`mx_labs_feature_${feature}`, enabled);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -85,6 +85,7 @@ module.exports.components['views.rooms.MemberList'] = require('./components/view
|
||||||
module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile');
|
module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile');
|
||||||
module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer');
|
module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer');
|
||||||
module.exports.components['views.rooms.MessageComposerInput'] = require('./components/views/rooms/MessageComposerInput');
|
module.exports.components['views.rooms.MessageComposerInput'] = require('./components/views/rooms/MessageComposerInput');
|
||||||
|
module.exports.components['views.rooms.MessageComposerInputOld'] = require('./components/views/rooms/MessageComposerInputOld');
|
||||||
module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel');
|
module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel');
|
||||||
module.exports.components['views.rooms.ReadReceiptMarker'] = require('./components/views/rooms/ReadReceiptMarker');
|
module.exports.components['views.rooms.ReadReceiptMarker'] = require('./components/views/rooms/ReadReceiptMarker');
|
||||||
module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader');
|
module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader');
|
||||||
|
|
|
@ -64,6 +64,13 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
getInitialState: function() {
|
getInitialState: function() {
|
||||||
var s = {
|
var s = {
|
||||||
|
// If we are viewing a room by alias, this contains the alias
|
||||||
|
currentRoomAlias: null,
|
||||||
|
|
||||||
|
// The ID of the room we're viewing. This is either populated directly
|
||||||
|
// in the case where we view a room by ID or by RoomView when it resolves
|
||||||
|
// what ID an alias points at.
|
||||||
|
currentRoomId: null,
|
||||||
logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
|
logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
|
||||||
collapse_lhs: false,
|
collapse_lhs: false,
|
||||||
collapse_rhs: false,
|
collapse_rhs: false,
|
||||||
|
@ -392,10 +399,10 @@ module.exports = React.createClass({
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'view_room':
|
case 'view_room':
|
||||||
// Takes both room ID and room alias: if switching to a room the client is already
|
// Takes either a room ID or room alias: if switching to a room the client is already
|
||||||
// know to be in (eg. user clicks on a room in the recents panel), supply only the
|
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
|
||||||
// ID. If the user is clicking on a room in the context of the alias being presented
|
// If the user is clicking on a room in the context of the alias being presented
|
||||||
// to them, supply the room alias and optionally the room ID.
|
// to them, supply the room alias. If both are supplied, the room ID will be ignored.
|
||||||
this._viewRoom(
|
this._viewRoom(
|
||||||
payload.room_id, payload.room_alias, payload.show_settings, payload.event_id,
|
payload.room_id, payload.room_alias, payload.show_settings, payload.event_id,
|
||||||
payload.third_party_invite, payload.oob_data
|
payload.third_party_invite, payload.oob_data
|
||||||
|
@ -409,7 +416,7 @@ module.exports = React.createClass({
|
||||||
);
|
);
|
||||||
var roomIndex = -1;
|
var roomIndex = -1;
|
||||||
for (var i = 0; i < allRooms.length; ++i) {
|
for (var i = 0; i < allRooms.length; ++i) {
|
||||||
if (allRooms[i].roomId == this.state.currentRoom) {
|
if (allRooms[i].roomId == this.state.currentRoomId) {
|
||||||
roomIndex = i;
|
roomIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -506,18 +513,11 @@ module.exports = React.createClass({
|
||||||
page_type: this.PageTypes.RoomView,
|
page_type: this.PageTypes.RoomView,
|
||||||
thirdPartyInvite: thirdPartyInvite,
|
thirdPartyInvite: thirdPartyInvite,
|
||||||
roomOobData: oob_data,
|
roomOobData: oob_data,
|
||||||
|
currentRoomAlias: roomAlias,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If an alias has been provided, we use that and only that,
|
if (!roomAlias) {
|
||||||
// since otherwise we'll prefer to pass in an ID to RoomView
|
newState.currentRoomId = roomId;
|
||||||
// but if we're not in the room, we should join by alias rather
|
|
||||||
// than ID.
|
|
||||||
if (roomAlias) {
|
|
||||||
newState.currentRoomAlias = roomAlias;
|
|
||||||
newState.currentRoom = null;
|
|
||||||
} else {
|
|
||||||
newState.currentRoomAlias = null;
|
|
||||||
newState.currentRoom = roomId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we aren't given an explicit event id, look for one in the
|
// if we aren't given an explicit event id, look for one in the
|
||||||
|
@ -612,13 +612,13 @@ module.exports = React.createClass({
|
||||||
dis.dispatch(self.starting_room_alias_payload);
|
dis.dispatch(self.starting_room_alias_payload);
|
||||||
delete self.starting_room_alias_payload;
|
delete self.starting_room_alias_payload;
|
||||||
} else if (!self.state.page_type) {
|
} else if (!self.state.page_type) {
|
||||||
if (!self.state.currentRoom) {
|
if (!self.state.currentRoomId) {
|
||||||
var firstRoom = null;
|
var firstRoom = null;
|
||||||
if (cli.getRooms() && cli.getRooms().length) {
|
if (cli.getRooms() && cli.getRooms().length) {
|
||||||
firstRoom = RoomListSorter.mostRecentActivityFirst(
|
firstRoom = RoomListSorter.mostRecentActivityFirst(
|
||||||
cli.getRooms()
|
cli.getRooms()
|
||||||
)[0].roomId;
|
)[0].roomId;
|
||||||
self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView});
|
self.setState({ready: true, currentRoomId: firstRoom, page_type: self.PageTypes.RoomView});
|
||||||
} else {
|
} else {
|
||||||
self.setState({ready: true, page_type: self.PageTypes.RoomDirectory});
|
self.setState({ready: true, page_type: self.PageTypes.RoomDirectory});
|
||||||
}
|
}
|
||||||
|
@ -628,8 +628,8 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
// we notifyNewScreen now because now the room will actually be displayed,
|
// we notifyNewScreen now because now the room will actually be displayed,
|
||||||
// and (mostly) now we can get the correct alias.
|
// and (mostly) now we can get the correct alias.
|
||||||
var presentedId = self.state.currentRoom;
|
var presentedId = self.state.currentRoomId;
|
||||||
var room = MatrixClientPeg.get().getRoom(self.state.currentRoom);
|
var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
|
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
|
||||||
if (theAlias) presentedId = theAlias;
|
if (theAlias) presentedId = theAlias;
|
||||||
|
@ -979,10 +979,10 @@ module.exports = React.createClass({
|
||||||
onUserSettingsClose: function() {
|
onUserSettingsClose: function() {
|
||||||
// XXX: use browser history instead to find the previous room?
|
// XXX: use browser history instead to find the previous room?
|
||||||
// or maintain a this.state.pageHistory in _setPage()?
|
// or maintain a this.state.pageHistory in _setPage()?
|
||||||
if (this.state.currentRoom) {
|
if (this.state.currentRoomId) {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'view_room',
|
action: 'view_room',
|
||||||
room_id: this.state.currentRoom,
|
room_id: this.state.currentRoomId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
@ -992,6 +992,13 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onRoomIdResolved: function(room_id) {
|
||||||
|
// It's the RoomView's resposibility to look up room aliases, but we need the
|
||||||
|
// ID to pass into things like the Member List, so the Room View tells us when
|
||||||
|
// its done that resolution so we can display things that take a room ID.
|
||||||
|
this.setState({currentRoomId: room_id});
|
||||||
|
},
|
||||||
|
|
||||||
render: function() {
|
render: function() {
|
||||||
var LeftPanel = sdk.getComponent('structures.LeftPanel');
|
var LeftPanel = sdk.getComponent('structures.LeftPanel');
|
||||||
var RoomView = sdk.getComponent('structures.RoomView');
|
var RoomView = sdk.getComponent('structures.RoomView');
|
||||||
|
@ -1022,17 +1029,18 @@ module.exports = React.createClass({
|
||||||
page_element = (
|
page_element = (
|
||||||
<RoomView
|
<RoomView
|
||||||
ref="roomView"
|
ref="roomView"
|
||||||
roomAddress={this.state.currentRoom || this.state.currentRoomAlias}
|
roomAddress={this.state.currentRoomAlias || this.state.currentRoomId}
|
||||||
|
onRoomIdResolved={this.onRoomIdResolved}
|
||||||
eventId={this.state.initialEventId}
|
eventId={this.state.initialEventId}
|
||||||
thirdPartyInvite={this.state.thirdPartyInvite}
|
thirdPartyInvite={this.state.thirdPartyInvite}
|
||||||
oobData={this.state.roomOobData}
|
oobData={this.state.roomOobData}
|
||||||
highlightedEventId={this.state.highlightedEventId}
|
highlightedEventId={this.state.highlightedEventId}
|
||||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||||
key={this.state.currentRoom || this.state.currentRoomAlias}
|
key={this.state.currentRoomAlias || this.state.currentRoomId}
|
||||||
opacity={this.state.middleOpacity}
|
opacity={this.state.middleOpacity}
|
||||||
ConferenceHandler={this.props.ConferenceHandler} />
|
ConferenceHandler={this.props.ConferenceHandler} />
|
||||||
);
|
);
|
||||||
right_panel = <RightPanel roomId={this.state.currentRoom} collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity} />
|
right_panel = <RightPanel roomId={this.state.currentRoomId} collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity} />
|
||||||
break;
|
break;
|
||||||
case this.PageTypes.UserSettings:
|
case this.PageTypes.UserSettings:
|
||||||
page_element = <UserSettings onClose={this.onUserSettingsClose} version={this.state.version} brand={this.props.config.brand} />
|
page_element = <UserSettings onClose={this.onUserSettingsClose} version={this.state.version} brand={this.props.config.brand} />
|
||||||
|
@ -1068,7 +1076,7 @@ module.exports = React.createClass({
|
||||||
<div className="mx_MatrixChat_wrapper">
|
<div className="mx_MatrixChat_wrapper">
|
||||||
{topBar}
|
{topBar}
|
||||||
<div className={bodyClasses}>
|
<div className={bodyClasses}>
|
||||||
<LeftPanel selectedRoom={this.state.currentRoom} collapsed={this.state.collapse_lhs} opacity={this.state.sideOpacity}/>
|
<LeftPanel selectedRoom={this.state.currentRoomId} collapsed={this.state.collapse_lhs} opacity={this.state.sideOpacity}/>
|
||||||
<main className="mx_MatrixChat_middlePanel">
|
<main className="mx_MatrixChat_middlePanel">
|
||||||
{page_element}
|
{page_element}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -55,9 +55,17 @@ module.exports = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
ConferenceHandler: React.PropTypes.any,
|
ConferenceHandler: React.PropTypes.any,
|
||||||
|
|
||||||
// the ID for this room (or, if we don't know it, an alias for it)
|
// Either a room ID or room alias for the room to display.
|
||||||
|
// If the room is being displayed as a result of the user clicking
|
||||||
|
// on a room alias, the alias should be supplied. Otherwise, a room
|
||||||
|
// ID should be supplied.
|
||||||
roomAddress: React.PropTypes.string.isRequired,
|
roomAddress: React.PropTypes.string.isRequired,
|
||||||
|
|
||||||
|
// If a room alias is passed to roomAddress, a function can be
|
||||||
|
// provided here that will be called with the ID of the room
|
||||||
|
// once it has been resolved.
|
||||||
|
onRoomIdResolved: React.PropTypes.func,
|
||||||
|
|
||||||
// An object representing a third party invite to join this room
|
// An object representing a third party invite to join this room
|
||||||
// Fields:
|
// Fields:
|
||||||
// * inviteSignUrl (string) The URL used to join this room from an email invite
|
// * inviteSignUrl (string) The URL used to join this room from an email invite
|
||||||
|
@ -94,24 +102,17 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState: function() {
|
getInitialState: function() {
|
||||||
var room;
|
|
||||||
if (this.props.roomAddress[0] == '!') {
|
|
||||||
room = MatrixClientPeg.get().getRoom(this.props.roomAddress);
|
|
||||||
} else {
|
|
||||||
room = MatrixTools.getRoomForAlias(
|
|
||||||
MatrixClientPeg.get().getRooms(), this.props.roomAddress
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
room: room,
|
room: null,
|
||||||
roomLoading: !room,
|
roomId: null,
|
||||||
|
roomLoading: true,
|
||||||
editingRoomSettings: false,
|
editingRoomSettings: false,
|
||||||
uploadingRoomSettings: false,
|
uploadingRoomSettings: false,
|
||||||
numUnreadMessages: 0,
|
numUnreadMessages: 0,
|
||||||
draggingFile: false,
|
draggingFile: false,
|
||||||
searching: false,
|
searching: false,
|
||||||
searchResults: null,
|
searchResults: null,
|
||||||
hasUnsentMessages: this._hasUnsentMessages(room),
|
hasUnsentMessages: false,
|
||||||
callState: null,
|
callState: null,
|
||||||
guestsCanJoin: false,
|
guestsCanJoin: false,
|
||||||
canPeek: false,
|
canPeek: false,
|
||||||
|
@ -143,6 +144,39 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.props.roomAddress[0] == '#') {
|
||||||
|
// we always look up the alias from the directory server:
|
||||||
|
// we want the room that the given alias is pointing to
|
||||||
|
// right now. We may have joined that alias before but there's
|
||||||
|
// no guarantee the alias hasn't subsequently been remapped.
|
||||||
|
MatrixClientPeg.get().getRoomIdForAlias(this.props.roomAddress).done((result) => {
|
||||||
|
if (this.props.onRoomIdResolved) {
|
||||||
|
this.props.onRoomIdResolved(result.room_id);
|
||||||
|
}
|
||||||
|
var room = MatrixClientPeg.get().getRoom(result.room_id);
|
||||||
|
this.setState({
|
||||||
|
room: room,
|
||||||
|
roomId: result.room_id,
|
||||||
|
roomLoading: !room,
|
||||||
|
hasUnsentMessages: this._hasUnsentMessages(room),
|
||||||
|
}, this._updatePeeking);
|
||||||
|
}, (err) => {
|
||||||
|
this.setState({
|
||||||
|
roomLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var room = MatrixClientPeg.get().getRoom(this.props.roomAddress);
|
||||||
|
this.setState({
|
||||||
|
roomId: this.props.roomAddress,
|
||||||
|
room: room,
|
||||||
|
roomLoading: !room,
|
||||||
|
hasUnsentMessages: this._hasUnsentMessages(room),
|
||||||
|
}, this._updatePeeking);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_updatePeeking: function() {
|
||||||
// if this is an unknown room then we're in one of three states:
|
// if this is an unknown room then we're in one of three states:
|
||||||
// - This is a room we can peek into (search engine) (we can /peek)
|
// - This is a room we can peek into (search engine) (we can /peek)
|
||||||
// - This is a room we can publicly join or were invited to. (we can /join)
|
// - This is a room we can publicly join or were invited to. (we can /join)
|
||||||
|
@ -150,10 +184,13 @@ module.exports = React.createClass({
|
||||||
// We can't try to /join because this may implicitly accept invites (!)
|
// We can't try to /join because this may implicitly accept invites (!)
|
||||||
// We can /peek though. If it fails then we present the join UI. If it
|
// We can /peek though. If it fails then we present the join UI. If it
|
||||||
// succeeds then great, show the preview (but we still may be able to /join!).
|
// succeeds then great, show the preview (but we still may be able to /join!).
|
||||||
if (!this.state.room) {
|
// Note that peeking works by room ID and room ID only, as opposed to joining
|
||||||
console.log("Attempting to peek into room %s", this.props.roomAddress);
|
// which must be by alias or invite wherever possible (peeking currently does
|
||||||
|
// not work over federation).
|
||||||
|
if (!this.state.room && this.state.roomId) {
|
||||||
|
console.log("Attempting to peek into room %s", this.state.roomId);
|
||||||
|
|
||||||
MatrixClientPeg.get().peekInRoom(this.props.roomAddress).then((room) => {
|
MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
room: room,
|
room: room,
|
||||||
roomLoading: false,
|
roomLoading: false,
|
||||||
|
@ -172,7 +209,7 @@ module.exports = React.createClass({
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}).done();
|
}).done();
|
||||||
} else {
|
} else if (this.state.room) {
|
||||||
MatrixClientPeg.get().stopPeeking();
|
MatrixClientPeg.get().stopPeeking();
|
||||||
this._onRoomLoaded(this.state.room);
|
this._onRoomLoaded(this.state.room);
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,17 @@ var GeminiScrollbar = require('react-gemini-scrollbar');
|
||||||
var Email = require('../../email');
|
var Email = require('../../email');
|
||||||
var AddThreepid = require('../../AddThreepid');
|
var AddThreepid = require('../../AddThreepid');
|
||||||
|
|
||||||
|
const LABS_FEATURES = [
|
||||||
|
{
|
||||||
|
name: 'Rich Text Editor',
|
||||||
|
id: 'rich_text_editor'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'End-to-End Encryption',
|
||||||
|
id: 'e2e_encryption'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = React.createClass({
|
module.exports = React.createClass({
|
||||||
displayName: 'UserSettings',
|
displayName: 'UserSettings',
|
||||||
|
|
||||||
|
@ -247,6 +258,10 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderDeviceInfo: function() {
|
_renderDeviceInfo: function() {
|
||||||
|
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var client = MatrixClientPeg.get();
|
var client = MatrixClientPeg.get();
|
||||||
var deviceId = client.deviceId;
|
var deviceId = client.deviceId;
|
||||||
var olmKey = client.getDeviceEd25519Key() || "<not supported>";
|
var olmKey = client.getDeviceEd25519Key() || "<not supported>";
|
||||||
|
@ -357,6 +372,30 @@ module.exports = React.createClass({
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._renderLabs = function () {
|
||||||
|
let features = LABS_FEATURES.map(feature => (
|
||||||
|
<div key={feature.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={feature.id}
|
||||||
|
name={feature.id}
|
||||||
|
defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)}
|
||||||
|
onChange={e => UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} />
|
||||||
|
<label htmlFor={feature.id}>{feature.name}</label>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Labs</h3>
|
||||||
|
|
||||||
|
<div className="mx_UserSettings_section">
|
||||||
|
<p>These are experimental features that may break in unexpected ways. Use with caution.</p>
|
||||||
|
{features}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserSettings">
|
<div className="mx_UserSettings">
|
||||||
<SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/>
|
<SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/>
|
||||||
|
@ -411,6 +450,8 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
{this._renderDeviceInfo()}
|
{this._renderDeviceInfo()}
|
||||||
|
|
||||||
|
{this._renderLabs()}
|
||||||
|
|
||||||
<h3>Advanced</h3>
|
<h3>Advanced</h3>
|
||||||
|
|
||||||
<div className="mx_UserSettings_section">
|
<div className="mx_UserSettings_section">
|
||||||
|
|
|
@ -30,6 +30,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||||
var dis = require("../../../dispatcher");
|
var dis = require("../../../dispatcher");
|
||||||
var Modal = require("../../../Modal");
|
var Modal = require("../../../Modal");
|
||||||
var sdk = require('../../../index');
|
var sdk = require('../../../index');
|
||||||
|
var UserSettingsStore = require('../../../UserSettingsStore');
|
||||||
var createRoom = require('../../../createRoom');
|
var createRoom = require('../../../createRoom');
|
||||||
|
|
||||||
module.exports = React.createClass({
|
module.exports = React.createClass({
|
||||||
|
@ -506,6 +507,10 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderDevices: function() {
|
_renderDevices: function() {
|
||||||
|
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var devices = this.state.devices;
|
var devices = this.state.devices;
|
||||||
var MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo');
|
var MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo');
|
||||||
var Spinner = sdk.getComponent("elements.Spinner");
|
var Spinner = sdk.getComponent("elements.Spinner");
|
||||||
|
|
|
@ -22,6 +22,8 @@ var sdk = require('../../../index');
|
||||||
var dis = require('../../../dispatcher');
|
var dis = require('../../../dispatcher');
|
||||||
import Autocomplete from './Autocomplete';
|
import Autocomplete from './Autocomplete';
|
||||||
|
|
||||||
|
import UserSettingsStore from '../../../UserSettingsStore';
|
||||||
|
|
||||||
|
|
||||||
module.exports = React.createClass({
|
module.exports = React.createClass({
|
||||||
displayName: 'MessageComposer',
|
displayName: 'MessageComposer',
|
||||||
|
@ -144,7 +146,8 @@ module.exports = React.createClass({
|
||||||
var uploadInputStyle = {display: 'none'};
|
var uploadInputStyle = {display: 'none'};
|
||||||
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||||
var TintableSvg = sdk.getComponent("elements.TintableSvg");
|
var TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||||
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
|
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
|
||||||
|
(UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old"));
|
||||||
|
|
||||||
var controls = [];
|
var controls = [];
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
var React = require("react");
|
import React from 'react';
|
||||||
|
|
||||||
var marked = require("marked");
|
var marked = require("marked");
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
|
@ -27,6 +27,12 @@ marked.setOptions({
|
||||||
smartypants: false
|
smartypants: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import {Editor, EditorState, RichUtils, CompositeDecorator,
|
||||||
|
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
|
||||||
|
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
|
||||||
|
|
||||||
|
import {stateToMarkdown} from 'draft-js-export-markdown';
|
||||||
|
|
||||||
var MatrixClientPeg = require("../../../MatrixClientPeg");
|
var MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||||
var SlashCommands = require("../../../SlashCommands");
|
var SlashCommands = require("../../../SlashCommands");
|
||||||
var Modal = require("../../../Modal");
|
var Modal = require("../../../Modal");
|
||||||
|
@ -36,10 +42,13 @@ var sdk = require('../../../index');
|
||||||
var dis = require("../../../dispatcher");
|
var dis = require("../../../dispatcher");
|
||||||
var KeyCode = require("../../../KeyCode");
|
var KeyCode = require("../../../KeyCode");
|
||||||
|
|
||||||
var TYPING_USER_TIMEOUT = 10000;
|
import * as RichText from '../../../RichText';
|
||||||
var TYPING_SERVER_TIMEOUT = 30000;
|
|
||||||
var MARKDOWN_ENABLED = true;
|
|
||||||
|
|
||||||
|
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
const KEY_M = 77;
|
||||||
|
|
||||||
|
// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p>
|
||||||
function mdownToHtml(mdown) {
|
function mdownToHtml(mdown) {
|
||||||
var html = marked(mdown) || "";
|
var html = marked(mdown) || "";
|
||||||
html = html.trim();
|
html = html.trim();
|
||||||
|
@ -56,31 +65,63 @@ function mdownToHtml(mdown) {
|
||||||
/*
|
/*
|
||||||
* The textInput part of the MessageComposer
|
* The textInput part of the MessageComposer
|
||||||
*/
|
*/
|
||||||
module.exports = React.createClass({
|
export default class MessageComposerInput extends React.Component {
|
||||||
displayName: 'MessageComposerInput',
|
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.onChange = this.onChange.bind(this);
|
||||||
|
|
||||||
statics: {
|
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
|
||||||
// the height we limit the composer to
|
if(isRichtextEnabled == null) {
|
||||||
MAX_HEIGHT: 100,
|
isRichtextEnabled = 'true';
|
||||||
},
|
}
|
||||||
|
isRichtextEnabled = isRichtextEnabled === 'true';
|
||||||
|
|
||||||
propTypes: {
|
this.state = {
|
||||||
tabComplete: React.PropTypes.any,
|
isRichtextEnabled: isRichtextEnabled,
|
||||||
|
editorState: null
|
||||||
|
};
|
||||||
|
|
||||||
// a callback which is called when the height of the composer is
|
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
|
||||||
// changed due to a change in content.
|
this.state.editorState = this.createEditorState();
|
||||||
onResize: React.PropTypes.func,
|
|
||||||
|
|
||||||
// js-sdk Room object
|
this.client = MatrixClientPeg.get();
|
||||||
room: React.PropTypes.object.isRequired,
|
}
|
||||||
|
|
||||||
onContentChanged: React.PropTypes.func
|
static getKeyBinding(e: SyntheticKeyboardEvent): string {
|
||||||
},
|
// C-m => Toggles between rich text and markdown modes
|
||||||
|
if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
|
||||||
|
return 'toggle-mode';
|
||||||
|
}
|
||||||
|
|
||||||
componentWillMount: function() {
|
return getDefaultKeyBinding(e);
|
||||||
this.oldScrollHeight = 0;
|
}
|
||||||
this.markdownEnabled = MARKDOWN_ENABLED;
|
|
||||||
var self = this;
|
/**
|
||||||
|
* "Does the right thing" to create an EditorState, based on:
|
||||||
|
* - whether we've got rich text mode enabled
|
||||||
|
* - contentState was passed in
|
||||||
|
*/
|
||||||
|
createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
|
||||||
|
let decorators = richText ? RichText.getScopedRTDecorators(this.props) :
|
||||||
|
RichText.getScopedMDDecorators(this.props),
|
||||||
|
compositeDecorator = new CompositeDecorator(decorators);
|
||||||
|
|
||||||
|
let editorState = null;
|
||||||
|
if (contentState) {
|
||||||
|
editorState = EditorState.createWithContent(contentState, compositeDecorator);
|
||||||
|
} else {
|
||||||
|
editorState = EditorState.createEmpty(compositeDecorator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EditorState.moveFocusToEnd(editorState);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
const component = this;
|
||||||
this.sentHistory = {
|
this.sentHistory = {
|
||||||
// The list of typed messages. Index 0 is more recent
|
// The list of typed messages. Index 0 is more recent
|
||||||
data: [],
|
data: [],
|
||||||
|
@ -98,7 +139,7 @@ module.exports = React.createClass({
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.position = -1;
|
this.position = -1;
|
||||||
var storedData = window.sessionStorage.getItem(
|
var storedData = window.sessionStorage.getItem(
|
||||||
"history_" + roomId
|
"mx_messagecomposer_history_" + roomId
|
||||||
);
|
);
|
||||||
if (storedData) {
|
if (storedData) {
|
||||||
this.data = JSON.parse(storedData);
|
this.data = JSON.parse(storedData);
|
||||||
|
@ -112,7 +153,7 @@ module.exports = React.createClass({
|
||||||
// store a message in the sent history
|
// store a message in the sent history
|
||||||
this.data.unshift(text);
|
this.data.unshift(text);
|
||||||
window.sessionStorage.setItem(
|
window.sessionStorage.setItem(
|
||||||
"history_" + this.roomId,
|
"mx_messagecomposer_history_" + this.roomId,
|
||||||
JSON.stringify(this.data)
|
JSON.stringify(this.data)
|
||||||
);
|
);
|
||||||
// reset history position
|
// reset history position
|
||||||
|
@ -151,7 +192,6 @@ module.exports = React.createClass({
|
||||||
this.element.value = this.originalText;
|
this.element.value = this.originalText;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.resizeInput();
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -159,76 +199,68 @@ module.exports = React.createClass({
|
||||||
// save the currently entered text in order to restore it later.
|
// save the currently entered text in order to restore it later.
|
||||||
// NB: This isn't 'originalText' because we want to restore
|
// NB: This isn't 'originalText' because we want to restore
|
||||||
// sent history items too!
|
// sent history items too!
|
||||||
var text = this.element.value;
|
let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
|
||||||
window.sessionStorage.setItem("input_" + this.roomId, text);
|
window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON);
|
||||||
},
|
},
|
||||||
|
|
||||||
setLastTextEntry: function() {
|
setLastTextEntry: function() {
|
||||||
var text = window.sessionStorage.getItem("input_" + this.roomId);
|
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
|
||||||
if (text) {
|
if (contentJSON) {
|
||||||
this.element.value = text;
|
let content = convertFromRaw(JSON.parse(contentJSON));
|
||||||
self.resizeInput();
|
component.setState({
|
||||||
|
editorState: component.createEditorState(component.state.isRichtextEnabled, content)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
componentDidMount: function() {
|
componentDidMount() {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
this.sentHistory.init(
|
this.sentHistory.init(
|
||||||
this.refs.textarea,
|
this.refs.editor,
|
||||||
this.props.room.roomId
|
this.props.room.roomId
|
||||||
);
|
);
|
||||||
this.resizeInput();
|
// this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
|
||||||
if (this.props.tabComplete) {
|
// if (this.props.tabComplete) {
|
||||||
this.props.tabComplete.setTextArea(this.refs.textarea);
|
// this.props.tabComplete.setEditor(this.refs.editor);
|
||||||
}
|
// }
|
||||||
},
|
}
|
||||||
|
|
||||||
componentWillUnmount: function() {
|
componentWillUnmount() {
|
||||||
dis.unregister(this.dispatcherRef);
|
dis.unregister(this.dispatcherRef);
|
||||||
this.sentHistory.saveLastTextEntry();
|
this.sentHistory.saveLastTextEntry();
|
||||||
},
|
}
|
||||||
|
|
||||||
|
onAction(payload) {
|
||||||
|
var editor = this.refs.editor;
|
||||||
|
|
||||||
onAction: function(payload) {
|
|
||||||
var textarea = this.refs.textarea;
|
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'focus_composer':
|
case 'focus_composer':
|
||||||
textarea.focus();
|
editor.focus();
|
||||||
break;
|
break;
|
||||||
case 'insert_displayname':
|
|
||||||
if (textarea.value.length) {
|
|
||||||
var left = textarea.value.substring(0, textarea.selectionStart);
|
|
||||||
var right = textarea.value.substring(textarea.selectionEnd);
|
|
||||||
if (right.length) {
|
|
||||||
left += payload.displayname;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
left = left.replace(/( ?)$/, " " + payload.displayname);
|
|
||||||
}
|
|
||||||
textarea.value = left + right;
|
|
||||||
textarea.focus();
|
|
||||||
textarea.setSelectionRange(left.length, left.length);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
textarea.value = payload.displayname + ": ";
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onKeyDown: function (ev) {
|
// TODO change this so we insert a complete user alias
|
||||||
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) {
|
|
||||||
var input = this.refs.textarea.value;
|
case 'insert_displayname':
|
||||||
if (input.length === 0) {
|
if (this.state.editorState.getCurrentContent().hasText()) {
|
||||||
ev.preventDefault();
|
console.log(payload);
|
||||||
return;
|
let contentState = Modifier.replaceText(
|
||||||
}
|
this.state.editorState.getCurrentContent(),
|
||||||
this.sentHistory.push(input);
|
this.state.editorState.getSelection(),
|
||||||
this.onEnter(ev);
|
payload.displayname
|
||||||
|
);
|
||||||
|
this.setState({
|
||||||
|
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters')
|
||||||
|
});
|
||||||
|
editor.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
|
}
|
||||||
|
|
||||||
|
onKeyDown(ev) {
|
||||||
|
if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
|
||||||
var oldSelectionStart = this.refs.textarea.selectionStart;
|
var oldSelectionStart = this.refs.textarea.selectionStart;
|
||||||
// Remember the keyCode because React will recycle the synthetic event
|
// Remember the keyCode because React will recycle the synthetic event
|
||||||
var keyCode = ev.keyCode;
|
var keyCode = ev.keyCode;
|
||||||
|
@ -237,80 +269,167 @@ module.exports = React.createClass({
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.refs.textarea.selectionStart == oldSelectionStart) {
|
if (this.refs.textarea.selectionStart == oldSelectionStart) {
|
||||||
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
|
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
|
||||||
this.resizeInput();
|
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.props.tabComplete) {
|
onTypingActivity() {
|
||||||
this.props.tabComplete.onKeyDown(ev);
|
this.isTyping = true;
|
||||||
|
if (!this.userTypingTimer) {
|
||||||
|
this.sendTyping(true);
|
||||||
}
|
}
|
||||||
|
this.startUserTypingTimer();
|
||||||
|
this.startServerTypingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFinishedTyping() {
|
||||||
|
this.isTyping = false;
|
||||||
|
this.sendTyping(false);
|
||||||
|
this.stopUserTypingTimer();
|
||||||
|
this.stopServerTypingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
startUserTypingTimer() {
|
||||||
|
this.stopUserTypingTimer();
|
||||||
var self = this;
|
var self = this;
|
||||||
setTimeout(function() {
|
this.userTypingTimer = setTimeout(function() {
|
||||||
if (self.refs.textarea && self.refs.textarea.value != '') {
|
self.isTyping = false;
|
||||||
self.onTypingActivity();
|
self.sendTyping(self.isTyping);
|
||||||
} else {
|
self.userTypingTimer = null;
|
||||||
self.onFinishedTyping();
|
}, TYPING_USER_TIMEOUT);
|
||||||
}
|
}
|
||||||
}, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :(
|
|
||||||
},
|
|
||||||
|
|
||||||
resizeInput: function() {
|
stopUserTypingTimer() {
|
||||||
// scrollHeight is at least equal to clientHeight, so we have to
|
if (this.userTypingTimer) {
|
||||||
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
|
clearTimeout(this.userTypingTimer);
|
||||||
this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS
|
this.userTypingTimer = null;
|
||||||
var newHeight = Math.min(this.refs.textarea.scrollHeight,
|
|
||||||
this.constructor.MAX_HEIGHT);
|
|
||||||
this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
|
|
||||||
this.oldScrollHeight = this.refs.textarea.scrollHeight;
|
|
||||||
|
|
||||||
if (this.props.onResize) {
|
|
||||||
// kick gemini-scrollbar to re-layout
|
|
||||||
this.props.onResize();
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
onKeyUp: function(ev) {
|
startServerTypingTimer() {
|
||||||
if (this.refs.textarea.scrollHeight !== this.oldScrollHeight ||
|
if (!this.serverTypingTimer) {
|
||||||
ev.keyCode === KeyCode.DELETE ||
|
var self = this;
|
||||||
ev.keyCode === KeyCode.BACKSPACE)
|
this.serverTypingTimer = setTimeout(function() {
|
||||||
{
|
if (self.isTyping) {
|
||||||
this.resizeInput();
|
self.sendTyping(self.isTyping);
|
||||||
|
self.startServerTypingTimer();
|
||||||
|
}
|
||||||
|
}, TYPING_SERVER_TIMEOUT / 2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.props.onContentChanged && this.props.onContentChanged(this.refs.textarea.value);
|
|
||||||
},
|
|
||||||
|
|
||||||
onEnter: function(ev) {
|
stopServerTypingTimer() {
|
||||||
var contentText = this.refs.textarea.value;
|
if (this.serverTypingTimer) {
|
||||||
|
clearTimeout(this.servrTypingTimer);
|
||||||
// bodge for now to set markdown state on/off. We probably want a separate
|
this.serverTypingTimer = null;
|
||||||
// area for "local" commands which don't hit out to the server.
|
|
||||||
if (contentText.indexOf("/markdown") === 0) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.refs.textarea.value = '';
|
|
||||||
if (contentText.indexOf("/markdown on") === 0) {
|
|
||||||
this.markdownEnabled = true;
|
|
||||||
}
|
|
||||||
else if (contentText.indexOf("/markdown off") === 0) {
|
|
||||||
this.markdownEnabled = false;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
Modal.createDialog(ErrorDialog, {
|
|
||||||
title: "Unknown command",
|
|
||||||
description: "Usage: /markdown on|off"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping(isTyping) {
|
||||||
|
MatrixClientPeg.get().sendTyping(
|
||||||
|
this.props.room.roomId,
|
||||||
|
this.isTyping, TYPING_SERVER_TIMEOUT
|
||||||
|
).done();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTyping() {
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
this.typingTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputClick(ev) {
|
||||||
|
this.refs.editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(editorState: EditorState) {
|
||||||
|
this.setState({editorState});
|
||||||
|
|
||||||
|
if(editorState.getCurrentContent().hasText()) {
|
||||||
|
this.onTypingActivity()
|
||||||
|
} else {
|
||||||
|
this.onFinishedTyping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enableRichtext(enabled: boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
|
||||||
|
this.setState({
|
||||||
|
editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html))
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
|
||||||
|
contentState = ContentState.createFromText(markdown);
|
||||||
|
this.setState({
|
||||||
|
editorState: this.createEditorState(enabled, contentState)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem('mx_editor_rte_enabled', enabled);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isRichtextEnabled: enabled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyCommand(command: string): boolean {
|
||||||
|
if(command === 'toggle-mode') {
|
||||||
|
this.enableRichtext(!this.state.isRichtextEnabled);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let contentState = this.state.editorState.getCurrentContent(),
|
||||||
|
selection = this.state.editorState.getSelection();
|
||||||
|
|
||||||
|
let modifyFn = {
|
||||||
|
bold: text => `**${text}**`,
|
||||||
|
italic: text => `*${text}*`,
|
||||||
|
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
|
||||||
|
code: text => `\`${text}\``
|
||||||
|
}[command];
|
||||||
|
|
||||||
|
if(modifyFn) {
|
||||||
|
newState = EditorState.push(
|
||||||
|
this.state.editorState,
|
||||||
|
RichText.modifyText(contentState, selection, modifyFn),
|
||||||
|
'insert-characters'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(newState == null)
|
||||||
|
newState = RichUtils.handleKeyCommand(this.state.editorState, command);
|
||||||
|
|
||||||
|
if (newState != null) {
|
||||||
|
this.onChange(newState);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReturn(ev) {
|
||||||
|
if(ev.shiftKey)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const contentState = this.state.editorState.getCurrentContent();
|
||||||
|
if(!contentState.hasText())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
let contentText = contentState.getPlainText(), contentHTML;
|
||||||
|
|
||||||
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
|
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
ev.preventDefault();
|
|
||||||
if (!cmd.error) {
|
if (!cmd.error) {
|
||||||
this.refs.textarea.value = '';
|
this.setState({
|
||||||
|
editorState: this.createEditorState()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (cmd.promise) {
|
if (cmd.promise) {
|
||||||
cmd.promise.done(function() {
|
cmd.promise.done(function() {
|
||||||
|
@ -332,121 +451,75 @@ module.exports = React.createClass({
|
||||||
description: cmd.error
|
description: cmd.error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmote = /^\/me( |$)/i.test(contentText);
|
if(this.state.isRichtextEnabled) {
|
||||||
var sendMessagePromise;
|
contentHTML = RichText.contentStateToHTML(contentState);
|
||||||
|
} else {
|
||||||
if (isEmote) {
|
contentHTML = mdownToHtml(contentText);
|
||||||
contentText = contentText.substring(4);
|
|
||||||
}
|
|
||||||
else if (contentText[0] === '/') {
|
|
||||||
contentText = contentText.substring(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var htmlText;
|
let sendFn = this.client.sendHtmlMessage;
|
||||||
if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) {
|
|
||||||
sendMessagePromise = isEmote ?
|
if (contentText.startsWith('/me')) {
|
||||||
MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) :
|
contentText = contentText.replace('/me', '');
|
||||||
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
|
// bit of a hack, but the alternative would be quite complicated
|
||||||
}
|
contentHTML = contentHTML.replace('/me', '');
|
||||||
else {
|
sendFn = this.client.sendHtmlEmote;
|
||||||
sendMessagePromise = isEmote ?
|
|
||||||
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
|
|
||||||
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessagePromise.done(function() {
|
this.sentHistory.push(contentHTML);
|
||||||
|
let sendMessagePromise = sendFn.call(this.client, this.props.room.roomId, contentText, contentHTML);
|
||||||
|
|
||||||
|
sendMessagePromise.done(() => {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'message_sent'
|
action: 'message_sent'
|
||||||
});
|
});
|
||||||
}, function() {
|
}, () => {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'message_send_failed'
|
action: 'message_send_failed'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.refs.textarea.value = '';
|
|
||||||
this.resizeInput();
|
|
||||||
ev.preventDefault();
|
|
||||||
},
|
|
||||||
|
|
||||||
onTypingActivity: function() {
|
this.setState({
|
||||||
this.isTyping = true;
|
editorState: this.createEditorState()
|
||||||
if (!this.userTypingTimer) {
|
});
|
||||||
this.sendTyping(true);
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let className = "mx_MessageComposer_input";
|
||||||
|
|
||||||
|
if(this.state.isRichtextEnabled) {
|
||||||
|
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
|
||||||
}
|
}
|
||||||
this.startUserTypingTimer();
|
|
||||||
this.startServerTypingTimer();
|
|
||||||
},
|
|
||||||
|
|
||||||
onFinishedTyping: function() {
|
|
||||||
this.isTyping = false;
|
|
||||||
this.sendTyping(false);
|
|
||||||
this.stopUserTypingTimer();
|
|
||||||
this.stopServerTypingTimer();
|
|
||||||
},
|
|
||||||
|
|
||||||
startUserTypingTimer: function() {
|
|
||||||
this.stopUserTypingTimer();
|
|
||||||
var self = this;
|
|
||||||
this.userTypingTimer = setTimeout(function() {
|
|
||||||
self.isTyping = false;
|
|
||||||
self.sendTyping(self.isTyping);
|
|
||||||
self.userTypingTimer = null;
|
|
||||||
}, TYPING_USER_TIMEOUT);
|
|
||||||
},
|
|
||||||
|
|
||||||
stopUserTypingTimer: function() {
|
|
||||||
if (this.userTypingTimer) {
|
|
||||||
clearTimeout(this.userTypingTimer);
|
|
||||||
this.userTypingTimer = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
startServerTypingTimer: function() {
|
|
||||||
if (!this.serverTypingTimer) {
|
|
||||||
var self = this;
|
|
||||||
this.serverTypingTimer = setTimeout(function() {
|
|
||||||
if (self.isTyping) {
|
|
||||||
self.sendTyping(self.isTyping);
|
|
||||||
self.startServerTypingTimer();
|
|
||||||
}
|
|
||||||
}, TYPING_SERVER_TIMEOUT / 2);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
stopServerTypingTimer: function() {
|
|
||||||
if (this.serverTypingTimer) {
|
|
||||||
clearTimeout(this.servrTypingTimer);
|
|
||||||
this.serverTypingTimer = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendTyping: function(isTyping) {
|
|
||||||
MatrixClientPeg.get().sendTyping(
|
|
||||||
this.props.room.roomId,
|
|
||||||
this.isTyping, TYPING_SERVER_TIMEOUT
|
|
||||||
).done();
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshTyping: function() {
|
|
||||||
if (this.typingTimeout) {
|
|
||||||
clearTimeout(this.typingTimeout);
|
|
||||||
this.typingTimeout = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onInputClick: function(ev) {
|
|
||||||
this.refs.textarea.focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function() {
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
|
<div className={className}
|
||||||
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
|
onClick={ this.onInputClick }>
|
||||||
|
<Editor ref="editor"
|
||||||
|
placeholder="Type a message…"
|
||||||
|
editorState={this.state.editorState}
|
||||||
|
onChange={this.onChange}
|
||||||
|
keyBindingFn={MessageComposerInput.getKeyBinding}
|
||||||
|
handleKeyCommand={this.handleKeyCommand}
|
||||||
|
handleReturn={this.handleReturn}
|
||||||
|
stripPastedStyles={!this.state.isRichtextEnabled}
|
||||||
|
spellCheck={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
MessageComposerInput.propTypes = {
|
||||||
|
tabComplete: React.PropTypes.any,
|
||||||
|
|
||||||
|
// a callback which is called when the height of the composer is
|
||||||
|
// changed due to a change in content.
|
||||||
|
onResize: React.PropTypes.func,
|
||||||
|
|
||||||
|
// js-sdk Room object
|
||||||
|
room: React.PropTypes.object.isRequired
|
||||||
|
};
|
||||||
|
|
447
src/components/views/rooms/MessageComposerInputOld.js
Normal file
447
src/components/views/rooms/MessageComposerInputOld.js
Normal file
|
@ -0,0 +1,447 @@
|
||||||
|
/*
|
||||||
|
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 marked = require("marked");
|
||||||
|
marked.setOptions({
|
||||||
|
renderer: new marked.Renderer(),
|
||||||
|
gfm: true,
|
||||||
|
tables: true,
|
||||||
|
breaks: true,
|
||||||
|
pedantic: false,
|
||||||
|
sanitize: true,
|
||||||
|
smartLists: true,
|
||||||
|
smartypants: false
|
||||||
|
});
|
||||||
|
|
||||||
|
var MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||||
|
var SlashCommands = require("../../../SlashCommands");
|
||||||
|
var Modal = require("../../../Modal");
|
||||||
|
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
|
||||||
|
var sdk = require('../../../index');
|
||||||
|
|
||||||
|
var dis = require("../../../dispatcher");
|
||||||
|
var KeyCode = require("../../../KeyCode");
|
||||||
|
|
||||||
|
var TYPING_USER_TIMEOUT = 10000;
|
||||||
|
var TYPING_SERVER_TIMEOUT = 30000;
|
||||||
|
var MARKDOWN_ENABLED = true;
|
||||||
|
|
||||||
|
function mdownToHtml(mdown) {
|
||||||
|
var html = marked(mdown) || "";
|
||||||
|
html = html.trim();
|
||||||
|
// strip start and end <p> tags else you get 'orrible spacing
|
||||||
|
if (html.indexOf("<p>") === 0) {
|
||||||
|
html = html.substring("<p>".length);
|
||||||
|
}
|
||||||
|
if (html.lastIndexOf("</p>") === (html.length - "</p>".length)) {
|
||||||
|
html = html.substring(0, html.length - "</p>".length);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The textInput part of the MessageComposer
|
||||||
|
*/
|
||||||
|
module.exports = React.createClass({
|
||||||
|
displayName: 'MessageComposerInput',
|
||||||
|
|
||||||
|
statics: {
|
||||||
|
// the height we limit the composer to
|
||||||
|
MAX_HEIGHT: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
tabComplete: React.PropTypes.any,
|
||||||
|
|
||||||
|
// a callback which is called when the height of the composer is
|
||||||
|
// changed due to a change in content.
|
||||||
|
onResize: React.PropTypes.func,
|
||||||
|
|
||||||
|
// js-sdk Room object
|
||||||
|
room: React.PropTypes.object.isRequired,
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillMount: function() {
|
||||||
|
this.oldScrollHeight = 0;
|
||||||
|
this.markdownEnabled = MARKDOWN_ENABLED;
|
||||||
|
var self = this;
|
||||||
|
this.sentHistory = {
|
||||||
|
// The list of typed messages. Index 0 is more recent
|
||||||
|
data: [],
|
||||||
|
// The position in data currently displayed
|
||||||
|
position: -1,
|
||||||
|
// The room the history is for.
|
||||||
|
roomId: null,
|
||||||
|
// The original text before they hit UP
|
||||||
|
originalText: null,
|
||||||
|
// The textarea element to set text to.
|
||||||
|
element: null,
|
||||||
|
|
||||||
|
init: function(element, roomId) {
|
||||||
|
this.roomId = roomId;
|
||||||
|
this.element = element;
|
||||||
|
this.position = -1;
|
||||||
|
var storedData = window.sessionStorage.getItem(
|
||||||
|
"history_" + roomId
|
||||||
|
);
|
||||||
|
if (storedData) {
|
||||||
|
this.data = JSON.parse(storedData);
|
||||||
|
}
|
||||||
|
if (this.roomId) {
|
||||||
|
this.setLastTextEntry();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
push: function(text) {
|
||||||
|
// store a message in the sent history
|
||||||
|
this.data.unshift(text);
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"history_" + this.roomId,
|
||||||
|
JSON.stringify(this.data)
|
||||||
|
);
|
||||||
|
// reset history position
|
||||||
|
this.position = -1;
|
||||||
|
this.originalText = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// move in the history. Returns true if we managed to move.
|
||||||
|
next: function(offset) {
|
||||||
|
if (this.position === -1) {
|
||||||
|
// user is going into the history, save the current line.
|
||||||
|
this.originalText = this.element.value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// user may have modified this line in the history; remember it.
|
||||||
|
this.data[this.position] = this.element.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset > 0 && this.position === (this.data.length - 1)) {
|
||||||
|
// we've run out of history
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve the next item (bounded).
|
||||||
|
var newPosition = this.position + offset;
|
||||||
|
newPosition = Math.max(-1, newPosition);
|
||||||
|
newPosition = Math.min(newPosition, this.data.length - 1);
|
||||||
|
this.position = newPosition;
|
||||||
|
|
||||||
|
if (this.position !== -1) {
|
||||||
|
// show the message
|
||||||
|
this.element.value = this.data[this.position];
|
||||||
|
}
|
||||||
|
else if (this.originalText !== undefined) {
|
||||||
|
// restore the original text the user was typing.
|
||||||
|
this.element.value = this.originalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.resizeInput();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveLastTextEntry: function() {
|
||||||
|
// save the currently entered text in order to restore it later.
|
||||||
|
// NB: This isn't 'originalText' because we want to restore
|
||||||
|
// sent history items too!
|
||||||
|
var text = this.element.value;
|
||||||
|
window.sessionStorage.setItem("input_" + this.roomId, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLastTextEntry: function() {
|
||||||
|
var text = window.sessionStorage.getItem("input_" + this.roomId);
|
||||||
|
if (text) {
|
||||||
|
this.element.value = text;
|
||||||
|
self.resizeInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
componentDidMount: function() {
|
||||||
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
|
this.sentHistory.init(
|
||||||
|
this.refs.textarea,
|
||||||
|
this.props.room.roomId
|
||||||
|
);
|
||||||
|
this.resizeInput();
|
||||||
|
if (this.props.tabComplete) {
|
||||||
|
this.props.tabComplete.setTextArea(this.refs.textarea);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillUnmount: function() {
|
||||||
|
dis.unregister(this.dispatcherRef);
|
||||||
|
this.sentHistory.saveLastTextEntry();
|
||||||
|
},
|
||||||
|
|
||||||
|
onAction: function(payload) {
|
||||||
|
var textarea = this.refs.textarea;
|
||||||
|
switch (payload.action) {
|
||||||
|
case 'focus_composer':
|
||||||
|
textarea.focus();
|
||||||
|
break;
|
||||||
|
case 'insert_displayname':
|
||||||
|
if (textarea.value.length) {
|
||||||
|
var left = textarea.value.substring(0, textarea.selectionStart);
|
||||||
|
var right = textarea.value.substring(textarea.selectionEnd);
|
||||||
|
if (right.length) {
|
||||||
|
left += payload.displayname;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
left = left.replace(/( ?)$/, " " + payload.displayname);
|
||||||
|
}
|
||||||
|
textarea.value = left + right;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(left.length, left.length);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
textarea.value = payload.displayname + ": ";
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown: function (ev) {
|
||||||
|
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) {
|
||||||
|
var input = this.refs.textarea.value;
|
||||||
|
if (input.length === 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sentHistory.push(input);
|
||||||
|
this.onEnter(ev);
|
||||||
|
}
|
||||||
|
else 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);
|
||||||
|
this.resizeInput();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.tabComplete) {
|
||||||
|
this.props.tabComplete.onKeyDown(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function() {
|
||||||
|
if (self.refs.textarea && self.refs.textarea.value != '') {
|
||||||
|
self.onTypingActivity();
|
||||||
|
} else {
|
||||||
|
self.onFinishedTyping();
|
||||||
|
}
|
||||||
|
}, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :(
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeInput: function() {
|
||||||
|
// scrollHeight is at least equal to clientHeight, so we have to
|
||||||
|
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
|
||||||
|
this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS
|
||||||
|
var newHeight = Math.min(this.refs.textarea.scrollHeight,
|
||||||
|
this.constructor.MAX_HEIGHT);
|
||||||
|
this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
|
||||||
|
this.oldScrollHeight = this.refs.textarea.scrollHeight;
|
||||||
|
|
||||||
|
if (this.props.onResize) {
|
||||||
|
// kick gemini-scrollbar to re-layout
|
||||||
|
this.props.onResize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyUp: function(ev) {
|
||||||
|
if (this.refs.textarea.scrollHeight !== this.oldScrollHeight ||
|
||||||
|
ev.keyCode === KeyCode.DELETE ||
|
||||||
|
ev.keyCode === KeyCode.BACKSPACE)
|
||||||
|
{
|
||||||
|
this.resizeInput();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onEnter: function(ev) {
|
||||||
|
var contentText = this.refs.textarea.value;
|
||||||
|
|
||||||
|
// bodge for now to set markdown state on/off. We probably want a separate
|
||||||
|
// area for "local" commands which don't hit out to the server.
|
||||||
|
if (contentText.indexOf("/markdown") === 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.refs.textarea.value = '';
|
||||||
|
if (contentText.indexOf("/markdown on") === 0) {
|
||||||
|
this.markdownEnabled = true;
|
||||||
|
}
|
||||||
|
else if (contentText.indexOf("/markdown off") === 0) {
|
||||||
|
this.markdownEnabled = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
|
Modal.createDialog(ErrorDialog, {
|
||||||
|
title: "Unknown command",
|
||||||
|
description: "Usage: /markdown on|off"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
|
||||||
|
if (cmd) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!cmd.error) {
|
||||||
|
this.refs.textarea.value = '';
|
||||||
|
}
|
||||||
|
if (cmd.promise) {
|
||||||
|
cmd.promise.done(function() {
|
||||||
|
console.log("Command success.");
|
||||||
|
}, function(err) {
|
||||||
|
console.error("Command failure: %s", err);
|
||||||
|
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
|
Modal.createDialog(ErrorDialog, {
|
||||||
|
title: "Server error",
|
||||||
|
description: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (cmd.error) {
|
||||||
|
console.error(cmd.error);
|
||||||
|
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
|
Modal.createDialog(ErrorDialog, {
|
||||||
|
title: "Command error",
|
||||||
|
description: cmd.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmote = /^\/me( |$)/i.test(contentText);
|
||||||
|
var sendMessagePromise;
|
||||||
|
|
||||||
|
if (isEmote) {
|
||||||
|
contentText = contentText.substring(4);
|
||||||
|
}
|
||||||
|
else if (contentText[0] === '/') {
|
||||||
|
contentText = contentText.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var htmlText;
|
||||||
|
if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) {
|
||||||
|
sendMessagePromise = isEmote ?
|
||||||
|
MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) :
|
||||||
|
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sendMessagePromise = isEmote ?
|
||||||
|
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
|
||||||
|
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessagePromise.done(function() {
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'message_sent'
|
||||||
|
});
|
||||||
|
}, function() {
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'message_send_failed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.refs.textarea.value = '';
|
||||||
|
this.resizeInput();
|
||||||
|
ev.preventDefault();
|
||||||
|
},
|
||||||
|
|
||||||
|
onTypingActivity: function() {
|
||||||
|
this.isTyping = true;
|
||||||
|
if (!this.userTypingTimer) {
|
||||||
|
this.sendTyping(true);
|
||||||
|
}
|
||||||
|
this.startUserTypingTimer();
|
||||||
|
this.startServerTypingTimer();
|
||||||
|
},
|
||||||
|
|
||||||
|
onFinishedTyping: function() {
|
||||||
|
this.isTyping = false;
|
||||||
|
this.sendTyping(false);
|
||||||
|
this.stopUserTypingTimer();
|
||||||
|
this.stopServerTypingTimer();
|
||||||
|
},
|
||||||
|
|
||||||
|
startUserTypingTimer: function() {
|
||||||
|
this.stopUserTypingTimer();
|
||||||
|
var self = this;
|
||||||
|
this.userTypingTimer = setTimeout(function() {
|
||||||
|
self.isTyping = false;
|
||||||
|
self.sendTyping(self.isTyping);
|
||||||
|
self.userTypingTimer = null;
|
||||||
|
}, TYPING_USER_TIMEOUT);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopUserTypingTimer: function() {
|
||||||
|
if (this.userTypingTimer) {
|
||||||
|
clearTimeout(this.userTypingTimer);
|
||||||
|
this.userTypingTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startServerTypingTimer: function() {
|
||||||
|
if (!this.serverTypingTimer) {
|
||||||
|
var self = this;
|
||||||
|
this.serverTypingTimer = setTimeout(function() {
|
||||||
|
if (self.isTyping) {
|
||||||
|
self.sendTyping(self.isTyping);
|
||||||
|
self.startServerTypingTimer();
|
||||||
|
}
|
||||||
|
}, TYPING_SERVER_TIMEOUT / 2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopServerTypingTimer: function() {
|
||||||
|
if (this.serverTypingTimer) {
|
||||||
|
clearTimeout(this.servrTypingTimer);
|
||||||
|
this.serverTypingTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendTyping: function(isTyping) {
|
||||||
|
MatrixClientPeg.get().sendTyping(
|
||||||
|
this.props.room.roomId,
|
||||||
|
this.isTyping, TYPING_SERVER_TIMEOUT
|
||||||
|
).done();
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshTyping: function() {
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
this.typingTimeout = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onInputClick: function(ev) {
|
||||||
|
this.refs.textarea.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function() {
|
||||||
|
return (
|
||||||
|
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
|
||||||
|
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue