parent
ce40fa1a8f
commit
b62622a814
15 changed files with 407 additions and 194 deletions
16
.eslintrc
16
.eslintrc
|
@ -78,18 +78,26 @@
|
|||
/** react **/
|
||||
|
||||
// bind or arrow function in props causes performance issues
|
||||
"react/jsx-no-bind": ["error"],
|
||||
"react/jsx-no-bind": ["error", {
|
||||
"ignoreRefs": true
|
||||
}],
|
||||
"react/jsx-key": ["error"],
|
||||
"react/prefer-stateless-function": ["warn"],
|
||||
"react/sort-comp": ["warn"],
|
||||
|
||||
/** flowtype **/
|
||||
"flowtype/require-parameter-type": 1,
|
||||
"flowtype/require-parameter-type": [
|
||||
1,
|
||||
{
|
||||
"excludeArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"flowtype/define-flow-type": 1,
|
||||
"flowtype/require-return-type": [
|
||||
1,
|
||||
"always",
|
||||
{
|
||||
"annotateUndefined": "never"
|
||||
"annotateUndefined": "never",
|
||||
"excludeArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"flowtype/space-after-type-colon": [
|
||||
|
|
|
@ -158,5 +158,11 @@ React
|
|||
<Foo onClick={this.doStuff}> // Better
|
||||
<Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff
|
||||
```
|
||||
|
||||
Not doing so is acceptable in a single case; in function-refs:
|
||||
|
||||
```jsx
|
||||
<Foo ref={(self) => this.component = self}>
|
||||
```
|
||||
- Think about whether your component really needs state: are you duplicating
|
||||
information in component state that could be derived from the model?
|
||||
|
|
|
@ -62,8 +62,8 @@
|
|||
"babel-loader": "^5.4.0",
|
||||
"babel-polyfill": "^6.5.0",
|
||||
"eslint": "^2.13.1",
|
||||
"eslint-plugin-flowtype": "^2.3.0",
|
||||
"eslint-plugin-react": "^5.2.2",
|
||||
"eslint-plugin-flowtype": "^2.17.0",
|
||||
"eslint-plugin-react": "^6.2.1",
|
||||
"expect": "^1.16.0",
|
||||
"json-loader": "^0.5.3",
|
||||
"karma": "^0.13.22",
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import * as sdk from './index';
|
||||
import * as emojione from 'emojione';
|
||||
import {stateToHTML} from 'draft-js-export-html';
|
||||
import {SelectionRange} from "./autocomplete/Autocompleter";
|
||||
|
||||
const MARKDOWN_REGEX = {
|
||||
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
|
||||
|
@ -203,7 +204,7 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
|
|||
};
|
||||
}
|
||||
|
||||
export function textOffsetsToSelectionState({start, end}: {start: number, end: number},
|
||||
export function textOffsetsToSelectionState({start, end}: SelectionRange,
|
||||
contentBlocks: Array<ContentBlock>): SelectionState {
|
||||
let selectionState = SelectionState.createEmpty();
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
var MatrixClientPeg = require("./MatrixClientPeg");
|
||||
var dis = require("./dispatcher");
|
||||
var Tinter = require("./Tinter");
|
||||
import sdk from './index';
|
||||
import Modal from './Modal';
|
||||
|
||||
|
||||
class Command {
|
||||
|
@ -56,6 +58,16 @@ var success = function(promise) {
|
|||
};
|
||||
|
||||
var commands = {
|
||||
ddg: new Command("ddg", "<query>", function(roomId, args) {
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
// TODO Don't explain this away, actually show a search UI here.
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: "/ddg is not a command",
|
||||
description: "To use it, just wait for autocomplete results to load and tab through them.",
|
||||
});
|
||||
return success();
|
||||
}),
|
||||
|
||||
// Change your nickname
|
||||
nick: new Command("nick", "<display_name>", function(room_id, args) {
|
||||
if (args) {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import Q from 'q';
|
||||
import React from 'react';
|
||||
import type {Completion, SelectionRange} from './Autocompleter';
|
||||
|
||||
export default class AutocompleteProvider {
|
||||
constructor(commandRegex?: RegExp, fuseOpts?: any) {
|
||||
if(commandRegex) {
|
||||
if(!commandRegex.global) {
|
||||
if (commandRegex) {
|
||||
if (!commandRegex.global) {
|
||||
throw new Error('commandRegex must have global flag set');
|
||||
}
|
||||
this.commandRegex = commandRegex;
|
||||
|
@ -14,18 +14,24 @@ export default class AutocompleteProvider {
|
|||
/**
|
||||
* 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) {
|
||||
getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string {
|
||||
let commandRegex = this.commandRegex;
|
||||
|
||||
if (force && this.shouldForceComplete()) {
|
||||
console.log('forcing complete');
|
||||
commandRegex = /[^\W]+/g;
|
||||
}
|
||||
|
||||
if (commandRegex == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.commandRegex.lastIndex = 0;
|
||||
commandRegex.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = this.commandRegex.exec(query)) != null) {
|
||||
while ((match = commandRegex.exec(query)) != null) {
|
||||
let matchStart = match.index,
|
||||
matchEnd = matchStart + match[0].length;
|
||||
|
||||
if (selection.start <= matchEnd && selection.end >= matchStart) {
|
||||
return {
|
||||
command: match,
|
||||
|
@ -45,8 +51,8 @@ export default class AutocompleteProvider {
|
|||
};
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
return Q.when([]);
|
||||
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
|
||||
return [];
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
|
@ -57,4 +63,9 @@ export default class AutocompleteProvider {
|
|||
console.error('stub; should be implemented in subclasses');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
||||
shouldForceComplete(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,63 @@
|
|||
// @flow
|
||||
|
||||
import type {Component} from 'react';
|
||||
import CommandProvider from './CommandProvider';
|
||||
import DuckDuckGoProvider from './DuckDuckGoProvider';
|
||||
import RoomProvider from './RoomProvider';
|
||||
import UserProvider from './UserProvider';
|
||||
import EmojiProvider from './EmojiProvider';
|
||||
import Q from 'q';
|
||||
|
||||
export type SelectionRange = {
|
||||
start: number,
|
||||
end: number
|
||||
};
|
||||
|
||||
export type Completion = {
|
||||
completion: string,
|
||||
component: ?Component,
|
||||
range: SelectionRange,
|
||||
command: ?string,
|
||||
};
|
||||
|
||||
const PROVIDERS = [
|
||||
UserProvider,
|
||||
CommandProvider,
|
||||
DuckDuckGoProvider,
|
||||
RoomProvider,
|
||||
EmojiProvider,
|
||||
CommandProvider,
|
||||
DuckDuckGoProvider,
|
||||
].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,
|
||||
};
|
||||
});
|
||||
// Providers will get rejected if they take longer than this.
|
||||
const PROVIDER_COMPLETION_TIMEOUT = 3000;
|
||||
|
||||
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
|
||||
/* Note: That this waits for all providers to return is *intentional*
|
||||
otherwise, we run into a condition where new completions are displayed
|
||||
while the user is interacting with the list, which makes it difficult
|
||||
to predict whether an action will actually do what is intended
|
||||
|
||||
It ends up containing a list of Q promise states, which are objects with
|
||||
state (== "fulfilled" || "rejected") and value. */
|
||||
const completionsList = await Q.allSettled(
|
||||
PROVIDERS.map(provider => {
|
||||
return Q(provider.getCompletions(query, selection, force))
|
||||
.timeout(PROVIDER_COMPLETION_TIMEOUT);
|
||||
})
|
||||
);
|
||||
|
||||
return completionsList
|
||||
.filter(completion => completion.state === "fulfilled")
|
||||
.map((completionsState, i) => {
|
||||
return {
|
||||
completions: completionsState.value,
|
||||
provider: PROVIDERS[i],
|
||||
|
||||
/* the currently matched "command" the completer tried to complete
|
||||
* we pass this through so that Autocomplete can figure out when to
|
||||
* re-show itself once hidden.
|
||||
*/
|
||||
command: PROVIDERS[i].getCurrentCommand(query, selection, force),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import Q from 'q';
|
||||
import Fuse from 'fuse.js';
|
||||
import {TextualCompletion} from './Components';
|
||||
|
||||
|
@ -23,7 +22,7 @@ const COMMANDS = [
|
|||
{
|
||||
command: '/invite',
|
||||
args: '<user-id>',
|
||||
description: 'Invites user with given id to current room'
|
||||
description: 'Invites user with given id to current room',
|
||||
},
|
||||
{
|
||||
command: '/join',
|
||||
|
@ -40,6 +39,11 @@ const COMMANDS = [
|
|||
args: '<display-name>',
|
||||
description: 'Changes your display nickname',
|
||||
},
|
||||
{
|
||||
command: '/ddg',
|
||||
args: '<query>',
|
||||
description: 'Searches DuckDuckGo for results',
|
||||
}
|
||||
];
|
||||
|
||||
let COMMAND_RE = /(^\/\w*)/g;
|
||||
|
@ -54,7 +58,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
async getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
let completions = [];
|
||||
let {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
|
@ -70,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
};
|
||||
});
|
||||
}
|
||||
return Q.when(completions);
|
||||
return completions;
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import Q from 'q';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
import {TextualCompletion} from './Components';
|
||||
|
@ -20,61 +19,59 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
|
|||
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
async getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
let {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (!query || !command) {
|
||||
return Q.when([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
|
||||
const response = await 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;
|
||||
});
|
||||
const json = await response.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() {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import Q from 'q';
|
||||
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
|
||||
import Fuse from 'fuse.js';
|
||||
import sdk from '../index';
|
||||
import {PillCompletion} from './Components';
|
||||
import type {SelectionRange, Completion} from './Autocompleter';
|
||||
|
||||
const EMOJI_REGEX = /:\w*:?/g;
|
||||
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
|
||||
|
@ -17,7 +17,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
this.fuse = new Fuse(EMOJI_SHORTNAMES);
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
async getCompletions(query: string, selection: SelectionRange) {
|
||||
const EmojiText = sdk.getComponent('views.elements.EmojiText');
|
||||
|
||||
let completions = [];
|
||||
|
@ -35,7 +35,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
};
|
||||
}).slice(0, 8);
|
||||
}
|
||||
return Q.when(completions);
|
||||
return completions;
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import Q from 'q';
|
||||
import MatrixClientPeg from '../MatrixClientPeg';
|
||||
import Fuse from 'fuse.js';
|
||||
import {PillCompletion} from './Components';
|
||||
|
@ -21,12 +20,12 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
|
||||
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
|
||||
|
||||
let client = MatrixClientPeg.get();
|
||||
let completions = [];
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
const {command, range} = this.getCurrentCommand(query, selection, force);
|
||||
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 => {
|
||||
|
@ -48,7 +47,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
};
|
||||
}).slice(0, 4);
|
||||
}
|
||||
return Q.when(completions);
|
||||
return completions;
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
@ -68,4 +67,8 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
{completions}
|
||||
</div>;
|
||||
}
|
||||
|
||||
shouldForceComplete(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,11 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
getCompletions(query: string, selection: {start: number, end: number}) {
|
||||
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
|
||||
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
|
||||
|
||||
let completions = [];
|
||||
let {command, range} = this.getCurrentCommand(query, selection);
|
||||
let {command, range} = this.getCurrentCommand(query, selection, force);
|
||||
if (command) {
|
||||
this.fuse.set(this.users);
|
||||
completions = this.fuse.search(command[0]).map(user => {
|
||||
|
@ -37,11 +37,11 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
title={displayName}
|
||||
description={user.userId} />
|
||||
),
|
||||
range
|
||||
range,
|
||||
};
|
||||
}).slice(0, 4);
|
||||
}
|
||||
return Q.when(completions);
|
||||
return completions;
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
@ -64,4 +64,8 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
{completions}
|
||||
</div>;
|
||||
}
|
||||
|
||||
shouldForceComplete(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,17 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import sdk from '../../../index';
|
||||
import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter';
|
||||
|
||||
import {getCompletions} from '../../../autocomplete/Autocompleter';
|
||||
|
||||
const COMPOSER_SELECTED = 0;
|
||||
|
||||
export default class Autocomplete extends React.Component {
|
||||
completionPromise: Promise = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -19,79 +25,137 @@ export default class Autocomplete extends React.Component {
|
|||
// array of completions, so we can look up current selection by offset quickly
|
||||
completionList: [],
|
||||
|
||||
// how far down the completion list we are
|
||||
selectionOffset: 0,
|
||||
// how far down the completion list we are (THIS IS 1-INDEXED!)
|
||||
selectionOffset: COMPOSER_SELECTED,
|
||||
|
||||
// whether we should show completions if they're available
|
||||
shouldShowCompletions: true,
|
||||
|
||||
hide: false,
|
||||
|
||||
forceComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(props, state) {
|
||||
async componentWillReceiveProps(props, state) {
|
||||
if (props.query === this.props.query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.complete(props.query, props.selection);
|
||||
}
|
||||
|
||||
async complete(query, selection) {
|
||||
let forceComplete = this.state.forceComplete;
|
||||
const completionPromise = getCompletions(query, selection, forceComplete);
|
||||
this.completionPromise = completionPromise;
|
||||
const completions = await this.completionPromise;
|
||||
|
||||
// There's a newer completion request, so ignore results.
|
||||
if (completionPromise !== this.completionPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
getCompletions(props.query, props.selection).forEach(completionResult => {
|
||||
try {
|
||||
completionResult.completions.then(completions => {
|
||||
let i = this.state.completions.findIndex(
|
||||
completion => completion.provider === completionResult.provider
|
||||
);
|
||||
const completionList = flatMap(completions, provider => provider.completions);
|
||||
|
||||
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);
|
||||
// Reset selection when completion list becomes empty.
|
||||
let selectionOffset = COMPOSER_SELECTED;
|
||||
if (completionList.length > 0) {
|
||||
/* If the currently selected completion is still in the completion list,
|
||||
try to find it and jump to it. If not, select composer.
|
||||
*/
|
||||
const currentSelection = this.state.selectionOffset === 0 ? null :
|
||||
this.state.completionList[this.state.selectionOffset - 1].completion;
|
||||
selectionOffset = completionList.findIndex(
|
||||
completion => completion.completion === currentSelection);
|
||||
if (selectionOffset === -1) {
|
||||
selectionOffset = COMPOSER_SELECTED;
|
||||
} else {
|
||||
selectionOffset++; // selectionOffset is 1-indexed!
|
||||
}
|
||||
} else {
|
||||
// If no completions were returned, we should turn off force completion.
|
||||
forceComplete = false;
|
||||
}
|
||||
|
||||
let hide = this.state.hide;
|
||||
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern
|
||||
const oldMatches = this.state.completions.map(completion => !!completion.command.command),
|
||||
newMatches = completions.map(completion => !!completion.command.command);
|
||||
|
||||
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
|
||||
if (!isEqual(oldMatches, newMatches)) {
|
||||
hide = false;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
completions,
|
||||
completionList,
|
||||
selectionOffset,
|
||||
hide,
|
||||
forceComplete,
|
||||
});
|
||||
}
|
||||
|
||||
countCompletions(): number {
|
||||
return this.state.completions.map(completionResult => {
|
||||
return completionResult.completions.length;
|
||||
}).reduce((l, r) => l + r);
|
||||
return this.state.completionList.length;
|
||||
}
|
||||
|
||||
// called from MessageComposerInput
|
||||
onUpArrow(): boolean {
|
||||
let completionCount = this.countCompletions(),
|
||||
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount;
|
||||
onUpArrow(): ?Completion {
|
||||
const completionCount = this.countCompletions();
|
||||
// completionCount + 1, since 0 means composer is selected
|
||||
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
|
||||
% (completionCount + 1);
|
||||
if (!completionCount) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
this.setSelection(selectionOffset);
|
||||
return true;
|
||||
return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
|
||||
}
|
||||
|
||||
// called from MessageComposerInput
|
||||
onDownArrow(): boolean {
|
||||
let completionCount = this.countCompletions(),
|
||||
selectionOffset = (this.state.selectionOffset + 1) % completionCount;
|
||||
onDownArrow(): ?Completion {
|
||||
const completionCount = this.countCompletions();
|
||||
// completionCount + 1, since 0 means composer is selected
|
||||
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
|
||||
if (!completionCount) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
this.setSelection(selectionOffset);
|
||||
return true;
|
||||
return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
|
||||
}
|
||||
|
||||
onEscape(e): boolean {
|
||||
const completionCount = this.countCompletions();
|
||||
if (completionCount === 0) {
|
||||
// autocomplete is already empty, so don't preventDefault
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// selectionOffset = 0, so we don't end up completing when autocomplete is hidden
|
||||
this.setState({hide: true, selectionOffset: 0});
|
||||
}
|
||||
|
||||
forceComplete() {
|
||||
this.setState({
|
||||
forceComplete: true,
|
||||
}, () => {
|
||||
this.complete(this.props.query, this.props.selection);
|
||||
});
|
||||
}
|
||||
|
||||
/** called from MessageComposerInput
|
||||
* @returns {boolean} whether confirmation was handled
|
||||
*/
|
||||
onConfirm(): boolean {
|
||||
if (this.countCompletions() === 0) {
|
||||
if (this.countCompletions() === 0 || this.state.selectionOffset === COMPOSER_SELECTED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let selectedCompletion = this.state.completionList[this.state.selectionOffset];
|
||||
let selectedCompletion = this.state.completionList[this.state.selectionOffset - 1];
|
||||
this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion);
|
||||
|
||||
return true;
|
||||
|
@ -117,7 +181,7 @@ export default class Autocomplete extends React.Component {
|
|||
render() {
|
||||
const EmojiText = sdk.getComponent('views.elements.EmojiText');
|
||||
|
||||
let position = 0;
|
||||
let position = 1;
|
||||
let renderedCompletions = this.state.completions.map((completionResult, i) => {
|
||||
let completions = completionResult.completions.map((completion, i) => {
|
||||
|
||||
|
@ -135,7 +199,7 @@ export default class Autocomplete extends React.Component {
|
|||
|
||||
return React.cloneElement(completion.component, {
|
||||
key: i,
|
||||
ref: `completion${i}`,
|
||||
ref: `completion${position - 1}`,
|
||||
className,
|
||||
onMouseOver,
|
||||
onClick,
|
||||
|
@ -151,7 +215,7 @@ export default class Autocomplete extends React.Component {
|
|||
) : null;
|
||||
}).filter(completion => !!completion);
|
||||
|
||||
return renderedCompletions.length > 0 ? (
|
||||
return !this.state.hide && renderedCompletions.length > 0 ? (
|
||||
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
|
||||
{renderedCompletions}
|
||||
</div>
|
||||
|
|
|
@ -166,7 +166,7 @@ export default class MessageComposer extends React.Component {
|
|||
|
||||
_onAutocompleteConfirm(range, completion) {
|
||||
if (this.messageComposerInput) {
|
||||
this.messageComposerInput.onConfirmAutocompletion(range, completion);
|
||||
this.messageComposerInput.setDisplayedCompletion(range, completion);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -313,7 +313,6 @@ export default class MessageComposer extends React.Component {
|
|||
|
||||
return (
|
||||
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
|
||||
{autoComplete}
|
||||
<div className="mx_MessageComposer_wrapper">
|
||||
<div className="mx_MessageComposer_row">
|
||||
{controls}
|
||||
|
|
|
@ -34,6 +34,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
|
|||
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
|
||||
import classNames from 'classnames';
|
||||
import escape from 'lodash/escape';
|
||||
import Q from 'q';
|
||||
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
|
||||
|
@ -46,6 +47,8 @@ import KeyCode from '../../../KeyCode';
|
|||
import UserSettingsStore from '../../../UserSettingsStore';
|
||||
|
||||
import * as RichText from '../../../RichText';
|
||||
import Autocomplete from './Autocomplete';
|
||||
import {Completion} from "../../../autocomplete/Autocompleter";
|
||||
|
||||
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
|
||||
|
||||
|
@ -88,34 +91,52 @@ export default class MessageComposerInput extends React.Component {
|
|||
return getDefaultKeyBinding(e);
|
||||
}
|
||||
|
||||
static getBlockStyle(block: ContentBlock): ?string {
|
||||
if (block.getType() === 'strikethrough') {
|
||||
return 'mx_Markdown_STRIKETHROUGH';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
client: MatrixClient;
|
||||
autocomplete: Autocomplete;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this.handleReturn = this.handleReturn.bind(this);
|
||||
this.handleKeyCommand = this.handleKeyCommand.bind(this);
|
||||
this.onEditorContentChanged = this.onEditorContentChanged.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);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
|
||||
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
|
||||
|
||||
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
|
||||
|
||||
this.state = {
|
||||
// whether we're in rich text or markdown mode
|
||||
isRichtextEnabled,
|
||||
|
||||
// the currently displayed editor state (note: this is always what is modified on input)
|
||||
editorState: null,
|
||||
|
||||
// the original editor state, before we started tabbing through completions
|
||||
originalEditorState: null,
|
||||
};
|
||||
|
||||
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
|
||||
/* eslint react/no-direct-mutation-state:0 */
|
||||
this.state.editorState = this.createEditorState();
|
||||
|
||||
this.client = MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* "Does the right thing" to create an EditorState, based on:
|
||||
* - whether we've got rich text mode enabled
|
||||
* - contentState was passed in
|
||||
|
@ -234,10 +255,6 @@ export default class MessageComposerInput extends React.Component {
|
|||
this.refs.editor,
|
||||
this.props.room.roomId
|
||||
);
|
||||
// this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
|
||||
// if (this.props.tabComplete) {
|
||||
// this.props.tabComplete.setEditor(this.refs.editor);
|
||||
// }
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -273,7 +290,7 @@ export default class MessageComposerInput extends React.Component {
|
|||
);
|
||||
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
|
||||
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
|
||||
this.setEditorState(editorState);
|
||||
this.onEditorContentChanged(editorState);
|
||||
editor.focus();
|
||||
}
|
||||
break;
|
||||
|
@ -295,10 +312,11 @@ export default class MessageComposerInput extends React.Component {
|
|||
startSelection,
|
||||
blockMap);
|
||||
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
|
||||
if (this.state.isRichtextEnabled)
|
||||
if (this.state.isRichtextEnabled) {
|
||||
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
|
||||
}
|
||||
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
|
||||
this.setEditorState(editorState);
|
||||
this.onEditorContentChanged(editorState);
|
||||
editor.focus();
|
||||
}
|
||||
}
|
||||
|
@ -372,10 +390,16 @@ export default class MessageComposerInput extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
setEditorState(editorState: EditorState, cb = () => null) {
|
||||
// Called by Draft to change editor contents, and by setEditorState
|
||||
onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) {
|
||||
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
|
||||
this.setState({editorState}, cb);
|
||||
|
||||
const setPromise = Q.defer();
|
||||
/* If a modification was made, set originalEditorState to null, since newState is now our original */
|
||||
this.setState({
|
||||
editorState,
|
||||
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState,
|
||||
}, () => setPromise.resolve());
|
||||
|
||||
if (editorState.getCurrentContent().hasText()) {
|
||||
this.onTypingActivity();
|
||||
|
@ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component {
|
|||
|
||||
this.props.onContentChanged(textContent, selection);
|
||||
}
|
||||
return setPromise;
|
||||
}
|
||||
|
||||
setEditorState(editorState: EditorState) {
|
||||
this.onEditorContentChanged(editorState, false);
|
||||
}
|
||||
|
||||
enableRichtext(enabled: boolean) {
|
||||
|
@ -470,7 +499,7 @@ export default class MessageComposerInput extends React.Component {
|
|||
|
||||
handleReturn(ev) {
|
||||
if (ev.shiftKey) {
|
||||
this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
|
||||
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -547,41 +576,68 @@ export default class MessageComposerInput extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
onUpArrow(e) {
|
||||
if (this.props.onUpArrow && this.props.onUpArrow()) {
|
||||
async onUpArrow(e) {
|
||||
const completion = this.autocomplete.onUpArrow();
|
||||
if (completion != null) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return await this.setDisplayedCompletion(completion);
|
||||
}
|
||||
|
||||
async onDownArrow(e) {
|
||||
const completion = this.autocomplete.onDownArrow();
|
||||
e.preventDefault();
|
||||
return await this.setDisplayedCompletion(completion);
|
||||
}
|
||||
|
||||
// tab and shift-tab are mapped to down and up arrow respectively
|
||||
async onTab(e) {
|
||||
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
|
||||
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
|
||||
if (!didTab && this.autocomplete) {
|
||||
this.autocomplete.forceComplete();
|
||||
}
|
||||
}
|
||||
|
||||
onDownArrow(e) {
|
||||
if (this.props.onDownArrow && this.props.onDownArrow()) {
|
||||
e.preventDefault();
|
||||
onEscape(e) {
|
||||
e.preventDefault();
|
||||
if (this.autocomplete) {
|
||||
this.autocomplete.onEscape(e);
|
||||
}
|
||||
this.setDisplayedCompletion(null); // restore originalEditorState
|
||||
}
|
||||
|
||||
onTab(e) {
|
||||
if (this.props.tryComplete) {
|
||||
if (this.props.tryComplete()) {
|
||||
e.preventDefault();
|
||||
/* If passed null, restores the original editor content from state.originalEditorState.
|
||||
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
|
||||
*/
|
||||
async setDisplayedCompletion(displayedCompletion: ?Completion): boolean {
|
||||
const activeEditorState = this.state.originalEditorState || this.state.editorState;
|
||||
|
||||
if (displayedCompletion == null) {
|
||||
if (this.state.originalEditorState) {
|
||||
this.setEditorState(this.state.originalEditorState);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onConfirmAutocompletion(range, content: string) {
|
||||
const {range = {}, completion = ''} = displayedCompletion;
|
||||
|
||||
let contentState = Modifier.replaceText(
|
||||
this.state.editorState.getCurrentContent(),
|
||||
RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()),
|
||||
content
|
||||
activeEditorState.getCurrentContent(),
|
||||
RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
|
||||
completion
|
||||
);
|
||||
|
||||
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
|
||||
|
||||
let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
|
||||
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
|
||||
const originalEditorState = activeEditorState;
|
||||
|
||||
this.setEditorState(editorState);
|
||||
await this.setEditorState(editorState);
|
||||
this.setState({originalEditorState});
|
||||
|
||||
// for some reason, doing this right away does not update the editor :(
|
||||
setTimeout(() => this.refs.editor.focus(), 50);
|
||||
return true;
|
||||
}
|
||||
|
||||
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
|
||||
|
@ -632,22 +688,14 @@ export default class MessageComposerInput extends React.Component {
|
|||
this.handleKeyCommand('toggle-mode');
|
||||
}
|
||||
|
||||
getBlockStyle(block: ContentBlock): ?string {
|
||||
if (block.getType() === 'strikethrough') {
|
||||
return 'mx_Markdown_STRIKETHROUGH';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {editorState} = this.state;
|
||||
const activeEditorState = this.state.originalEditorState || this.state.editorState;
|
||||
|
||||
// From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
|
||||
// If the user changes block type before entering any text, we can
|
||||
// either style the placeholder or hide it.
|
||||
let hidePlaceholder = false;
|
||||
const contentState = editorState.getCurrentContent();
|
||||
const contentState = activeEditorState.getCurrentContent();
|
||||
if (!contentState.hasText()) {
|
||||
if (contentState.getBlockMap().first().getType() !== 'unstyled') {
|
||||
hidePlaceholder = true;
|
||||
|
@ -655,28 +703,43 @@ export default class MessageComposerInput extends React.Component {
|
|||
}
|
||||
|
||||
const className = classNames('mx_MessageComposer_input', {
|
||||
mx_MessageComposer_input_empty: hidePlaceholder,
|
||||
mx_MessageComposer_input_empty: hidePlaceholder,
|
||||
});
|
||||
|
||||
const content = activeEditorState.getCurrentContent();
|
||||
const contentText = content.getPlainText();
|
||||
const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(),
|
||||
activeEditorState.getCurrentContent().getBlocksAsArray());
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<img className="mx_MessageComposer_input_markdownIndicator"
|
||||
onMouseDown={this.onMarkdownToggleClicked}
|
||||
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
|
||||
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
|
||||
<Editor ref="editor"
|
||||
placeholder="Type a message…"
|
||||
editorState={this.state.editorState}
|
||||
onChange={this.setEditorState}
|
||||
blockStyleFn={this.getBlockStyle}
|
||||
keyBindingFn={MessageComposerInput.getKeyBinding}
|
||||
handleKeyCommand={this.handleKeyCommand}
|
||||
handleReturn={this.handleReturn}
|
||||
stripPastedStyles={!this.state.isRichtextEnabled}
|
||||
onTab={this.onTab}
|
||||
onUpArrow={this.onUpArrow}
|
||||
onDownArrow={this.onDownArrow}
|
||||
spellCheck={true} />
|
||||
<div className="mx_MessageComposer_input_wrapper">
|
||||
<div className="mx_MessageComposer_autocomplete_wrapper">
|
||||
<Autocomplete
|
||||
ref={(e) => this.autocomplete = e}
|
||||
onConfirm={this.setDisplayedCompletion}
|
||||
query={contentText}
|
||||
selection={selection} />
|
||||
</div>
|
||||
<div className={className}>
|
||||
<img className="mx_MessageComposer_input_markdownIndicator"
|
||||
onMouseDown={this.onMarkdownToggleClicked}
|
||||
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
|
||||
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
|
||||
<Editor ref="editor"
|
||||
placeholder="Type a message…"
|
||||
editorState={this.state.editorState}
|
||||
onChange={this.onEditorContentChanged}
|
||||
blockStyleFn={MessageComposerInput.getBlockStyle}
|
||||
keyBindingFn={MessageComposerInput.getKeyBinding}
|
||||
handleKeyCommand={this.handleKeyCommand}
|
||||
handleReturn={this.handleReturn}
|
||||
stripPastedStyles={!this.state.isRichtextEnabled}
|
||||
onTab={this.onTab}
|
||||
onUpArrow={this.onUpArrow}
|
||||
onDownArrow={this.onDownArrow}
|
||||
onEscape={this.onEscape}
|
||||
spellCheck={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue