Merge pull request #296 from aviraldg/feature-autocomplete

Better autocomplete
This commit is contained in:
David Baker 2016-07-04 18:16:26 +01:00 committed by GitHub
commit 87300e3a9f
16 changed files with 875 additions and 80 deletions

View file

@ -36,7 +36,12 @@
"no-new-wrappers": ["error"], "no-new-wrappers": ["error"],
"no-invalid-regexp": ["error"], "no-invalid-regexp": ["error"],
"no-extra-bind": ["error"], "no-extra-bind": ["error"],
"no-magic-numbers": ["error"], "no-magic-numbers": ["error", {
"ignore": [-1, 0, 1], // usually used in array/string indexing
"ignoreArrayIndexes": true,
"enforceConst": true,
"detectObjects": true
}],
"consistent-return": ["error"], "consistent-return": ["error"],
"valid-jsdoc": ["error"], "valid-jsdoc": ["error"],
"no-use-before-define": ["error"], "no-use-before-define": ["error"],

View file

@ -29,21 +29,26 @@
"draft-js-export-html": "^0.2.2", "draft-js-export-html": "^0.2.2",
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6", "draft-js-import-markdown": "^0.1.6",
"emojione": "^2.2.2",
"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",
"fuse.js": "^2.2.0",
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^8.9.1", "highlight.js": "^8.9.1",
"linkifyjs": "^2.0.0-beta.4", "linkifyjs": "^2.0.0-beta.4",
"lodash": "^4.13.1",
"marked": "^0.3.5", "marked": "^0.3.5",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.0.1", "react": "^15.0.1",
"react-addons-css-transition-group": "^15.1.0",
"react-dom": "^15.0.1", "react-dom": "^15.0.1",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"velocity-vector": "vector-im/velocity#059e3b2" "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0"
}, },
"//babelversion": [ "//babelversion": [
"brief experiments with babel6 seems to show that it generates source ", "brief experiments with babel6 seems to show that it generates source ",

View file

@ -20,7 +20,7 @@ limitations under the License.
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
var GuestAccess = require("./GuestAccess"); var GuestAccess = require("./GuestAccess");
var matrixClient = null; let matrixClient: MatrixClient = null;
var localStorage = window.localStorage; var localStorage = window.localStorage;
@ -87,7 +87,7 @@ class MatrixClient {
this.guestAccess = guestAccess; this.guestAccess = guestAccess;
} }
get() { get(): MatrixClient {
return matrixClient; return matrixClient;
} }

View file

@ -1,13 +1,17 @@
import React from 'react';
import { import {
Editor, Editor,
Modifier, Modifier,
ContentState, ContentState,
ContentBlock,
convertFromHTML, convertFromHTML,
DefaultDraftBlockRenderMap, DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle, DefaultDraftInlineStyle,
CompositeDecorator CompositeDecorator,
SelectionState,
} from 'draft-js'; } from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione';
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: 'span' element: 'span'
@ -23,17 +27,18 @@ const STYLES = {
CODE: 'code', CODE: 'code',
ITALIC: 'em', ITALIC: 'em',
STRIKETHROUGH: 's', STRIKETHROUGH: 's',
UNDERLINE: 'u' UNDERLINE: 'u',
}; };
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g, ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
}; };
const USERNAME_REGEX = /@\S+:\S+/g; const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string { export function contentStateToHTML(contentState: ContentState): string {
return contentState.getBlockMap().map((block) => { return contentState.getBlockMap().map((block) => {
@ -88,6 +93,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
return <span className="mx_UserPill">{avatar} {props.children}</span>; return <span className="mx_UserPill">{avatar} {props.children}</span>;
} }
}; };
let roomDecorator = { let roomDecorator = {
strategy: (contentBlock, callback) => { strategy: (contentBlock, callback) => {
findWithRegex(ROOM_REGEX, contentBlock, callback); findWithRegex(ROOM_REGEX, contentBlock, callback);
@ -97,6 +103,16 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
} }
}; };
// Unused for now, due to https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
return <span dangerouslySetInnerHTML={{__html: ' ' + emojione.unicodeToImage(props.children[0].props.text)}}/>
}
};
return [usernameDecorator, roomDecorator]; return [usernameDecorator, roomDecorator];
} }
@ -153,7 +169,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
text = ""; text = "";
for(let currentKey = startKey; for (let currentKey = startKey;
currentKey && currentKey !== endKey; currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(currentKey)) { currentKey = contentState.getKeyAfter(currentKey)) {
let blockText = getText(currentKey); let blockText = getText(currentKey);
@ -168,3 +184,59 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey);
} }
/**
* Computes the plaintext offsets of the given SelectionState.
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc)
* Used by autocomplete to show completions when the current selection lies within, or at the edges of a command.
*/
export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0;
for(let block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset();
}
if (selectionState.getEndKey() === block.getKey()) {
end = offset + selectionState.getEndOffset();
break;
}
offset += block.getLength();
}
return {
start,
end,
};
}
export function textOffsetsToSelectionState({start, end}: {start: number, end: number},
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
for (let block of contentBlocks) {
let blockLength = block.getLength();
if (start !== -1 && start < blockLength) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1;
} else {
start -= blockLength;
}
if (end !== -1 && end <= blockLength) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1;
} else {
end -= blockLength;
}
}
return selectionState;
}

View file

@ -0,0 +1,54 @@
import Q from 'q';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
if(commandRegex) {
if(!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
}
this.commandRegex = commandRegex;
}
}
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/
getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array<string> {
if (this.commandRegex == null) {
return null;
}
this.commandRegex.lastIndex = 0;
let match;
while ((match = this.commandRegex.exec(query)) != null) {
let matchStart = match.index,
matchEnd = matchStart + match[0].length;
if (selection.start <= matchEnd && selection.end >= matchStart) {
return {
command: match,
range: {
start: matchStart,
end: matchEnd,
},
};
}
}
return {
command: null,
range: {
start: -1,
end: -1,
},
};
}
getCompletions(query: string, selection: {start: number, end: number}) {
return Q.when([]);
}
getName(): string {
return 'Default Provider';
}
}

View file

@ -0,0 +1,22 @@
import CommandProvider from './CommandProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
const PROVIDERS = [
UserProvider,
CommandProvider,
DuckDuckGoProvider,
RoomProvider,
EmojiProvider,
].map(completer => completer.getInstance());
export function getCompletions(query: string, selection: {start: number, end: number}) {
return PROVIDERS.map(provider => {
return {
completions: provider.getCompletions(query, selection),
provider,
};
});
}

View file

@ -0,0 +1,86 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
const COMMANDS = [
{
command: '/me',
args: '<message>',
description: 'Displays action',
},
{
command: '/ban',
args: '<user-id> [reason]',
description: 'Bans user with given id',
},
{
command: '/deop',
args: '<user-id>',
description: 'Deops user with given id',
},
{
command: '/invite',
args: '<user-id>',
description: 'Invites user with given id to current room'
},
{
command: '/join',
args: '<room-alias>',
description: 'Joins room with given alias',
},
{
command: '/kick',
args: '<user-id> [reason]',
description: 'Kicks user with given id',
},
{
command: '/nick',
args: '<display-name>',
description: 'Changes your display nickname',
},
];
let COMMAND_RE = /(^\/\w*)/g;
let instance = null;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, {
keys: ['command', 'args', 'description'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
return {
completion: result.command + ' ',
component: (<TextualCompletion
title={result.command}
subtitle={result.args}
description={result.description}
/>),
range,
};
});
}
return Q.when(completions);
}
getName() {
return 'Commands';
}
static getInstance(): CommandProvider {
if (instance == null)
instance = new CommandProvider();
return instance;
}
}

View file

@ -0,0 +1,19 @@
import React from 'react';
export function TextualCompletion({
title,
subtitle,
description,
}: {
title: ?string,
subtitle: ?string,
description: ?string
}) {
return (
<div style={{width: '100%'}}>
<span>{title}</span>
<em>{subtitle}</em>
<span style={{color: 'gray', float: 'right'}}>{description}</span>
</div>
);
}

View file

@ -0,0 +1,90 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import 'whatwg-fetch';
import {TextualCompletion} from './Components';
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() {
super(DDG_REGEX);
}
static getQueryUri(query: String) {
return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
getCompletions(query: string, selection: {start: number, end: number}) {
let {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return Q.when([]);
}
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET',
})
.then(response => response.json())
.then(json => {
let results = json.Results.map(result => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
});
}
getName() {
return 'Results from DuckDuckGo';
}
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
}
}

View file

@ -0,0 +1,48 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
let instance = null;
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES);
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
let shortname = EMOJI_SHORTNAMES[result];
let imageHTML = shortnameToImage(shortname);
return {
completion: shortnameToUnicode(shortname),
component: (
<div className="mx_Autocomplete_Completion">
<span dangerouslySetInnerHTML={{__html: imageHTML}}></span> {shortname}
</div>
),
range,
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Emoji';
}
static getInstance() {
if (instance == null)
instance = new EmojiProvider();
return instance;
}
}

View file

@ -0,0 +1,62 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
import {getDisplayAliasForRoom} from '../MatrixTools';
const ROOM_REGEX = /(?=#)([^\s]*)/g;
let instance = null;
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX, {
keys: ['displayName', 'userId'],
});
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => {
return {
room: room,
name: room.name,
roomId: room.roomId,
aliases: room.getAliases(),
};
}));
completions = this.fuse.search(command[0]).map(room => {
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
component: (
<TextualCompletion title={room.name} description={displayAlias} />
),
range,
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Rooms';
}
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
}
}

View file

@ -0,0 +1,55 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
const USER_REGEX = /@[^\s]*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider {
constructor() {
super(USER_REGEX, {
keys: ['displayName', 'userId'],
});
this.users = [];
this.fuse = new Fuse([], {
keys: ['displayName', 'userId'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => {
return {
completion: user.userId,
component: (
<TextualCompletion
title={user.displayName || user.userId}
description={user.userId} />
),
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Users';
}
setUserList(users) {
this.users = users;
}
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
}
return instance;
}
}

View file

@ -41,6 +41,8 @@ var rate_limited_func = require('../../ratelimitedfunc');
var ObjectUtils = require('../../ObjectUtils'); var ObjectUtils = require('../../ObjectUtils');
var MatrixTools = require('../../MatrixTools'); var MatrixTools = require('../../MatrixTools');
import UserProvider from '../../autocomplete/UserProvider';
var DEBUG = false; var DEBUG = false;
if (DEBUG) { if (DEBUG) {
@ -516,21 +518,23 @@ module.exports = React.createClass({
} }
}, },
_updateTabCompleteList: new rate_limited_func(function() { _updateTabCompleteList: function() {
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
if (!this.state.room || !this.tabComplete) { if (!this.state.room) {
return; return;
} }
var members = this.state.room.getJoinedMembers().filter(function(member) { var members = this.state.room.getJoinedMembers().filter(function(member) {
if (member.userId !== cli.credentials.userId) return true; if (member.userId !== cli.credentials.userId) return true;
}); });
UserProvider.getInstance().setUserList(members);
this.tabComplete.setCompletionList( this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(members).concat( MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList()) CommandEntry.fromCommands(SlashCommands.getCommandList())
) )
); );
}, 500), },
componentDidUpdate: function() { componentDidUpdate: function() {
if (this.refs.roomView) { if (this.refs.roomView) {

View file

@ -0,0 +1,158 @@
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import {getCompletions} from '../../../autocomplete/Autocompleter';
export default class Autocomplete extends React.Component {
constructor(props) {
super(props);
this.onConfirm = this.onConfirm.bind(this);
this.state = {
// list of completionResults, each containing completions
completions: [],
// array of completions, so we can look up current selection by offset quickly
completionList: [],
// how far down the completion list we are
selectionOffset: 0,
};
}
componentWillReceiveProps(props, state) {
if (props.query === this.props.query) {
return;
}
getCompletions(props.query, props.selection).forEach(completionResult => {
try {
completionResult.completions.then(completions => {
let i = this.state.completions.findIndex(
completion => completion.provider === completionResult.provider
);
i = i === -1 ? this.state.completions.length : i;
let newCompletions = Object.assign([], this.state.completions);
completionResult.completions = completions;
newCompletions[i] = completionResult;
this.setState({
completions: newCompletions,
completionList: flatMap(newCompletions, provider => provider.completions),
});
}, err => {
console.error(err);
});
} catch (e) {
// An error in one provider shouldn't mess up the rest.
console.error(e);
}
});
}
countCompletions(): number {
return this.state.completions.map(completionResult => {
return completionResult.completions.length;
}).reduce((l, r) => l + r);
}
// called from MessageComposerInput
onUpArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount;
this.setSelection(selectionOffset);
return true;
}
// called from MessageComposerInput
onDownArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (this.state.selectionOffset + 1) % completionCount;
this.setSelection(selectionOffset);
return true;
}
/** called from MessageComposerInput
* @returns {boolean} whether confirmation was handled
*/
onConfirm(): boolean {
if (this.countCompletions() === 0) {
return false;
}
let selectedCompletion = this.state.completionList[this.state.selectionOffset];
this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion);
return true;
}
setSelection(selectionOffset: number) {
this.setState({selectionOffset});
}
render() {
let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => {
let className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset,
});
let componentPosition = position;
position++;
let onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => {
this.setSelection(componentPosition);
this.onConfirm();
};
return (
<div key={i}
className={className}
onMouseOver={onMouseOver}
onClick={onClick}>
{completion.component}
</div>
);
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<span className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</span>
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{completions}
</ReactCSSTransitionGroup>
</div>
) : null;
});
return (
<div className="mx_Autocomplete">
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{renderedCompletions}
</ReactCSSTransitionGroup>
</div>
);
}
}
Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions
query: React.PropTypes.string.isRequired,
// method invoked with range and text content when completion is confirmed
onConfirm: React.PropTypes.func.isRequired,
};

View file

@ -20,54 +20,52 @@ var MatrixClientPeg = require('../../../MatrixClientPeg');
var Modal = require('../../../Modal'); var Modal = require('../../../Modal');
var sdk = require('../../../index'); var sdk = require('../../../index');
var dis = require('../../../dispatcher'); var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ export default class MessageComposer extends React.Component {
displayName: 'MessageComposer', constructor(props, context) {
super(props, context);
this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
this.onInputContentChanged = this.onInputContentChanged.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
propTypes: { this.state = {
tabComplete: React.PropTypes.any, autocompleteQuery: '',
selection: null,
};
// 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 onUploadClick(ev) {
room: React.PropTypes.object.isRequired,
// string representing the current voip call state
callState: React.PropTypes.string,
// callback when a file to upload is chosen
uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
},
onUploadClick: function(ev) {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, { Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register", title: "Please Register",
description: "Guest users can't upload files. Please register to upload." description: "Guest users can't upload files. Please register to upload.",
}); });
return; return;
} }
this.refs.uploadInput.click(); this.refs.uploadInput.click();
}, }
onUploadFileSelected: function(ev) { onUploadFileSelected(ev) {
var files = ev.target.files; let files = ev.target.files;
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); let TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileList = []; let fileList = [];
for(var i=0; i<files.length; i++) { for (let i=0; i<files.length; i++) {
fileList.push(<li> fileList.push(<li>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name} <TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name}
</li>); </li>);
@ -94,11 +92,11 @@ module.exports = React.createClass({
} }
this.refs.uploadInput.value = null; this.refs.uploadInput.value = null;
} },
}); });
}, }
onHangupClick: function() { onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId); var call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall(); //var call = CallHandler.getAnyActiveCall();
if (!call) { if (!call) {
@ -108,27 +106,46 @@ module.exports = React.createClass({
action: 'hangup', action: 'hangup',
// hangup the call for this room, which may not be the room in props // hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead) // (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId room_id: call.roomId,
}); });
}, }
onCallClick: function(ev) { onCallClick(ev) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId room_id: this.props.room.roomId,
}); });
}, }
onVoiceCallClick: function(ev) { onVoiceCallClick(ev) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: 'voice', type: 'voice',
room_id: this.props.room.roomId room_id: this.props.room.roomId,
}); });
}, }
render: function() { onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({
autocompleteQuery: content,
selection,
});
}
onUpArrow() {
return this.refs.autocomplete.onUpArrow();
}
onDownArrow() {
return this.refs.autocomplete.onDownArrow();
}
onTab() {
return this.refs.autocomplete.onTab();
}
render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
@ -155,11 +172,11 @@ module.exports = React.createClass({
callButton = callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call"> <div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call">
<TintableSvg src="img/voice.svg" width="16" height="26"/> <TintableSvg src="img/voice.svg" width="16" height="26"/>
</div> </div>;
videoCallButton = videoCallButton =
<div key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call"> <div key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call">
<TintableSvg src="img/call.svg" width="30" height="22"/> <TintableSvg src="img/call.svg" width="30" height="22"/>
</div> </div>;
} }
var canSendMessages = this.props.room.currentState.maySendMessage( var canSendMessages = this.props.room.currentState.maySendMessage(
@ -181,8 +198,16 @@ module.exports = React.createClass({
); );
controls.push( controls.push(
<MessageComposerInput key="controls_input" tabComplete={this.props.tabComplete} <MessageComposerInput
onResize={this.props.onResize} room={this.props.room} />, ref={c => this.messageComposerInput = c}
key="controls_input"
onResize={this.props.onResize}
room={this.props.room}
tryComplete={this.refs.autocomplete && this.refs.autocomplete.onConfirm}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onTab={this.onTab}
onContentChanged={this.onInputContentChanged} />,
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
@ -198,6 +223,13 @@ module.exports = React.createClass({
return ( return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}> <div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
<div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete
ref="autocomplete"
onConfirm={this.messageComposerInput && this.messageComposerInput.onConfirmAutocompletion}
query={this.state.autocompleteQuery}
selection={this.state.selection} />
</div>
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{controls} {controls}
@ -206,5 +238,24 @@ module.exports = React.createClass({
</div> </div>
); );
} }
}); };
MessageComposer.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,
// string representing the current voip call state
callState: React.PropTypes.string,
// callback when a file to upload is chosen
uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number
};

View file

@ -72,7 +72,11 @@ export default class MessageComposerInput extends React.Component {
this.onInputClick = this.onInputClick.bind(this); this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onChange = this.onChange.bind(this); this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if(isRichtextEnabled == null) { if(isRichtextEnabled == null) {
@ -82,7 +86,7 @@ export default class MessageComposerInput extends React.Component {
this.state = { this.state = {
isRichtextEnabled: isRichtextEnabled, isRichtextEnabled: isRichtextEnabled,
editorState: null editorState: null,
}; };
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
@ -93,7 +97,7 @@ export default class MessageComposerInput extends React.Component {
static getKeyBinding(e: SyntheticKeyboardEvent): string { static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes // C-m => Toggles between rich text and markdown modes
if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode'; return 'toggle-mode';
} }
@ -207,11 +211,9 @@ export default class MessageComposerInput extends React.Component {
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) { if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON)); let content = convertFromRaw(JSON.parse(contentJSON));
component.setState({ component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
editorState: component.createEditorState(component.state.isRichtextEnabled, content)
});
} }
} },
}; };
} }
@ -233,7 +235,7 @@ export default class MessageComposerInput extends React.Component {
} }
onAction(payload) { onAction(payload) {
var editor = this.refs.editor; let editor = this.refs.editor;
switch (payload.action) { switch (payload.action) {
case 'focus_composer': case 'focus_composer':
@ -251,7 +253,7 @@ export default class MessageComposerInput extends React.Component {
payload.displayname payload.displayname
); );
this.setState({ this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters') editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
}); });
editor.focus(); editor.focus();
} }
@ -344,7 +346,7 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor.focus(); this.refs.editor.focus();
} }
onChange(editorState: EditorState) { setEditorState(editorState: EditorState) {
this.setState({editorState}); this.setState({editorState});
if(editorState.getCurrentContent().hasText()) { if(editorState.getCurrentContent().hasText()) {
@ -352,20 +354,22 @@ export default class MessageComposerInput extends React.Component {
} else { } else {
this.onFinishedTyping(); this.onFinishedTyping();
} }
if(this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray()));
}
} }
enableRichtext(enabled: boolean) { enableRichtext(enabled: boolean) {
if (enabled) { if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setState({ this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html))
});
} else { } else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
contentState = ContentState.createFromText(markdown); contentState = ContentState.createFromText(markdown);
this.setState({ this.setEditorState(this.createEditorState(enabled, contentState));
editorState: this.createEditorState(enabled, contentState)
});
} }
window.localStorage.setItem('mx_editor_rte_enabled', enabled); window.localStorage.setItem('mx_editor_rte_enabled', enabled);
@ -408,19 +412,28 @@ export default class MessageComposerInput extends React.Component {
newState = RichUtils.handleKeyCommand(this.state.editorState, command); newState = RichUtils.handleKeyCommand(this.state.editorState, command);
if (newState != null) { if (newState != null) {
this.onChange(newState); this.setEditorState(newState);
return true; return true;
} }
return false; return false;
} }
handleReturn(ev) { handleReturn(ev) {
if(ev.shiftKey) if (ev.shiftKey) {
return false; return false;
}
if(this.props.tryComplete) {
if(this.props.tryComplete()) {
return true;
}
}
const contentState = this.state.editorState.getCurrentContent(); const contentState = this.state.editorState.getCurrentContent();
if(!contentState.hasText()) if (!contentState.hasText()) {
return true; return true;
}
let contentText = contentState.getPlainText(), contentHTML; let contentText = contentState.getPlainText(), contentHTML;
@ -489,10 +502,49 @@ export default class MessageComposerInput extends React.Component {
return true; return true;
} }
onUpArrow(e) {
if(this.props.onUpArrow) {
if(this.props.onUpArrow()) {
e.preventDefault();
}
}
}
onDownArrow(e) {
if(this.props.onDownArrow) {
if(this.props.onDownArrow()) {
e.preventDefault();
}
}
}
onTab(e) {
if (this.props.onTab) {
if (this.props.onTab()) {
e.preventDefault();
}
}
}
onConfirmAutocompletion(range, content: string) {
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()),
content
);
this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
});
// for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50);
}
render() { render() {
let className = "mx_MessageComposer_input"; let className = "mx_MessageComposer_input";
if(this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
} }
@ -502,11 +554,14 @@ export default class MessageComposerInput extends React.Component {
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder="Type a message…"
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={this.onChange} onChange={this.setEditorState}
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
spellCheck={true} /> spellCheck={true} />
</div> </div>
); );
@ -521,5 +576,14 @@ MessageComposerInput.propTypes = {
onResize: React.PropTypes.func, onResize: React.PropTypes.func,
// js-sdk Room object // js-sdk Room object
room: React.PropTypes.object.isRequired room: React.PropTypes.object.isRequired,
// called with current plaintext content (as a string) whenever it changes
onContentChanged: React.PropTypes.func,
onUpArrow: React.PropTypes.func,
onDownArrow: React.PropTypes.func,
onTab: React.PropTypes.func
}; };