feat: implement autocomplete replacement

This commit is contained in:
Aviral Dasgupta 2016-07-03 22:15:13 +05:30
parent 8961c87cf9
commit cccc58b47f
13 changed files with 271 additions and 121 deletions

View file

@ -37,6 +37,7 @@
"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",

View file

@ -1,12 +1,14 @@
import React from 'react';
import { import {
Editor, Editor,
Modifier, Modifier,
ContentState, ContentState,
ContentBlock,
convertFromHTML, convertFromHTML,
DefaultDraftBlockRenderMap, DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle, DefaultDraftInlineStyle,
CompositeDecorator, CompositeDecorator,
SelectionState SelectionState,
} from 'draft-js'; } from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
@ -25,7 +27,7 @@ const STYLES = {
CODE: 'code', CODE: 'code',
ITALIC: 'em', ITALIC: 'em',
STRIKETHROUGH: 's', STRIKETHROUGH: 's',
UNDERLINE: 'u' UNDERLINE: 'u',
}; };
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
@ -168,7 +170,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);
@ -189,14 +191,14 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) * 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. * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command.
*/ */
export function getTextSelectionOffsets(selectionState: SelectionState, export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} { contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0; let offset = 0, start = 0, end = 0;
for(let block of contentBlocks) { for(let block of contentBlocks) {
if (selectionState.getStartKey() == block.getKey()) { if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset(); start = offset + selectionState.getStartOffset();
} }
if (selectionState.getEndKey() == block.getKey()) { if (selectionState.getEndKey() === block.getKey()) {
end = offset + selectionState.getEndOffset(); end = offset + selectionState.getEndOffset();
break; break;
} }
@ -205,6 +207,37 @@ export function getTextSelectionOffsets(selectionState: SelectionState,
return { return {
start, start,
end 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

@ -14,25 +14,36 @@ export default class AutocompleteProvider {
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. * 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> { getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array<string> {
if(this.commandRegex == null) if (this.commandRegex == null) {
return null; return null;
}
let match = null; let match;
while((match = this.commandRegex.exec(query)) != null) { while ((match = this.commandRegex.exec(query)) != null) {
let matchStart = match.index, let matchStart = match.index,
matchEnd = matchStart + match[0].length; matchEnd = matchStart + match[0].length;
console.log(match);
if(selection.start <= matchEnd && selection.end >= matchStart) { if (selection.start <= matchEnd && selection.end >= matchStart) {
return match; return {
command: match,
range: {
start: matchStart,
end: matchEnd,
},
};
} }
} }
this.commandRegex.lastIndex = 0; this.commandRegex.lastIndex = 0;
return null; return {
command: null,
range: {
start: -1,
end: -1,
},
};
} }
getCompletions(query: String, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
return Q.when([]); return Q.when([]);
} }

View file

@ -9,14 +9,14 @@ const PROVIDERS = [
CommandProvider, CommandProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
RoomProvider, RoomProvider,
EmojiProvider EmojiProvider,
].map(completer => completer.getInstance()); ].map(completer => completer.getInstance());
export function getCompletions(query: string, selection: {start: number, end: number}) { export function getCompletions(query: string, selection: {start: number, end: number}) {
return PROVIDERS.map(provider => { return PROVIDERS.map(provider => {
return { return {
completions: provider.getCompletions(query, selection), completions: provider.getCompletions(query, selection),
provider provider,
}; };
}); });
} }

View file

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

View file

@ -1,13 +1,19 @@
export function TextualCompletion(props: { import React from 'react';
export function TextualCompletion({
title,
subtitle,
description,
}: {
title: ?string, title: ?string,
subtitle: ?string, subtitle: ?string,
description: ?string description: ?string
}) { }) {
return ( return (
<div className="mx_Autocomplete_Completion"> <div className="mx_Autocomplete_Completion">
<span>{completion.title}</span> <span>{title}</span>
<em>{completion.subtitle}</em> <em>{subtitle}</em>
<span style={{color: 'gray', float: 'right'}}>{completion.description}</span> <span style={{color: 'gray', float: 'right'}}>{description}</span>
</div> </div>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,23 @@
import React from 'react'; import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import classNames from 'classnames'; import classNames from 'classnames';
import _ from 'lodash';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
export default class Autocomplete extends React.Component { export default class Autocomplete extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.onConfirm = this.onConfirm.bind(this);
this.state = { this.state = {
// list of completionResults, each containing completions
completions: [], completions: [],
// array of completions, so we can look up current selection by offset quickly
completionList: [],
// how far down the completion list we are // how far down the completion list we are
selectionOffset: 0, selectionOffset: 0,
}; };
@ -31,8 +39,10 @@ export default class Autocomplete extends React.Component {
let newCompletions = Object.assign([], this.state.completions); let newCompletions = Object.assign([], this.state.completions);
completionResult.completions = completions; completionResult.completions = completions;
newCompletions[i] = completionResult; newCompletions[i] = completionResult;
this.setState({ this.setState({
completions: newCompletions, completions: newCompletions,
completionList: _.flatMap(newCompletions, provider => provider.completions),
}); });
}, err => { }, err => {
console.error(err); console.error(err);
@ -54,7 +64,7 @@ export default class Autocomplete extends React.Component {
onUpArrow(): boolean { onUpArrow(): boolean {
let completionCount = this.countCompletions(), let completionCount = this.countCompletions(),
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount;
this.setState({selectionOffset}); this.setSelection(selectionOffset);
return true; return true;
} }
@ -62,34 +72,49 @@ export default class Autocomplete extends React.Component {
onDownArrow(): boolean { onDownArrow(): boolean {
let completionCount = this.countCompletions(), let completionCount = this.countCompletions(),
selectionOffset = (this.state.selectionOffset + 1) % completionCount; selectionOffset = (this.state.selectionOffset + 1) % completionCount;
this.setState({selectionOffset}); this.setSelection(selectionOffset);
return true; 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() { render() {
let position = 0; let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, i) => { let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { let completions = completionResult.completions.map((completion, i) => {
let Component = completion.component;
let className = classNames('mx_Autocomplete_Completion', { let className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset, 'selected': position === this.state.selectionOffset,
}); });
let componentPosition = position; let componentPosition = position;
position++; position++;
if (Component) {
return Component;
}
let onMouseOver = () => this.setState({selectionOffset: componentPosition}); let onMouseOver = () => this.setSelection(componentPosition),
onClick = () => {
this.setSelection(componentPosition);
this.onConfirm();
};
return ( return (
<div key={i} <div key={i}
className={className} className={className}
onMouseOver={onMouseOver}> onMouseOver={onMouseOver}
<span style={{fontWeight: 600}}>{completion.title}</span> onClick={onClick}>
<span>{completion.subtitle}</span> {completion.component}
<span style={{flex: 1}} />
<span style={{color: 'gray'}}>{completion.description}</span>
</div> </div>
); );
}); });

View file

@ -40,16 +40,17 @@ export default class MessageComposer extends React.Component {
this.state = { this.state = {
autocompleteQuery: '', autocompleteQuery: '',
selection: null selection: null,
}; };
} }
onUploadClick(ev) { onUploadClick(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;
} }
@ -58,13 +59,13 @@ export default class MessageComposer extends React.Component {
} }
onUploadFileSelected(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>);
@ -91,7 +92,7 @@ export default class MessageComposer extends React.Component {
} }
this.refs.uploadInput.value = null; this.refs.uploadInput.value = null;
} },
}); });
} }
@ -105,7 +106,7 @@ export default class MessageComposer extends React.Component {
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,
}); });
} }
@ -113,7 +114,7 @@ export default class MessageComposer extends React.Component {
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,
}); });
} }
@ -121,14 +122,14 @@ export default class MessageComposer extends React.Component {
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,
}); });
} }
onInputContentChanged(content: string, selection: {start: number, end: number}) { onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({ this.setState({
autocompleteQuery: content, autocompleteQuery: content,
selection selection,
}); });
} }
@ -171,11 +172,11 @@ export default class MessageComposer extends React.Component {
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(
@ -198,9 +199,11 @@ export default class MessageComposer extends React.Component {
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c}
key="controls_input" key="controls_input"
onResize={this.props.onResize} onResize={this.props.onResize}
room={this.props.room} room={this.props.room}
tryComplete={this.refs.autocomplete && this.refs.autocomplete.onConfirm}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
onTab={this.onTab} onTab={this.onTab}
@ -223,6 +226,7 @@ export default class MessageComposer extends React.Component {
<div className="mx_MessageComposer_autocomplete_wrapper"> <div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete <Autocomplete
ref="autocomplete" ref="autocomplete"
onConfirm={this.messageComposerInput && this.messageComposerInput.onConfirmAutocompletion}
query={this.state.autocompleteQuery} query={this.state.autocompleteQuery}
selection={this.state.selection} /> selection={this.state.selection} />
</div> </div>

View file

@ -76,6 +76,7 @@ export default class MessageComposerInput extends React.Component {
this.onUpArrow = this.onUpArrow.bind(this); this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.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) {
@ -85,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
@ -96,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';
} }
@ -212,7 +213,7 @@ export default class MessageComposerInput extends React.Component {
let content = convertFromRaw(JSON.parse(contentJSON)); let content = convertFromRaw(JSON.parse(contentJSON));
component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
} }
} },
}; };
} }
@ -234,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':
@ -252,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();
} }
@ -356,7 +357,7 @@ export default class MessageComposerInput extends React.Component {
if(this.props.onContentChanged) { if(this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.getTextSelectionOffsets(editorState.getSelection(), RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray())); editorState.getCurrentContent().getBlocksAsArray()));
} }
} }
@ -418,12 +419,21 @@ export default class MessageComposerInput extends React.Component {
} }
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;
@ -509,17 +519,32 @@ export default class MessageComposerInput extends React.Component {
} }
onTab(e) { onTab(e) {
if(this.props.onTab) { if (this.props.onTab) {
if(this.props.onTab()) { if (this.props.onTab()) {
e.preventDefault(); 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
} }