Merge pull request #296 from aviraldg/feature-autocomplete
Better autocomplete
This commit is contained in:
commit
87300e3a9f
16 changed files with 875 additions and 80 deletions
|
@ -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"],
|
||||||
|
|
|
@ -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 ",
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
54
src/autocomplete/AutocompleteProvider.js
Normal file
54
src/autocomplete/AutocompleteProvider.js
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
22
src/autocomplete/Autocompleter.js
Normal file
22
src/autocomplete/Autocompleter.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
86
src/autocomplete/CommandProvider.js
Normal file
86
src/autocomplete/CommandProvider.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
19
src/autocomplete/Components.js
Normal file
19
src/autocomplete/Components.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
90
src/autocomplete/DuckDuckGoProvider.js
Normal file
90
src/autocomplete/DuckDuckGoProvider.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
48
src/autocomplete/EmojiProvider.js
Normal file
48
src/autocomplete/EmojiProvider.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
62
src/autocomplete/RoomProvider.js
Normal file
62
src/autocomplete/RoomProvider.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
55
src/autocomplete/UserProvider.js
Normal file
55
src/autocomplete/UserProvider.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
158
src/components/views/rooms/Autocomplete.js
Normal file
158
src/components/views/rooms/Autocomplete.js
Normal 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,
|
||||||
|
};
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue