Merge pull request #466 from aviraldg/feature-autocomplete-behaviour
Improve autocomplete behaviourpull/21833/head
						commit
						8bb9422907
					
				
							
								
								
									
										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,23 @@ 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()) { | ||||
|             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 +50,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 +62,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,19 +20,18 @@ 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 => { | ||||
|                 return { | ||||
|                     room: room, | ||||
|                     name: room.name, | ||||
|                     roomId: room.roomId, | ||||
|                     aliases: room.getAliases(), | ||||
|                 }; | ||||
|             })); | ||||
|  | @ -46,9 +44,9 @@ export default class RoomProvider extends AutocompleteProvider { | |||
|                     ), | ||||
|                     range, | ||||
|                 }; | ||||
|             }).slice(0, 4); | ||||
|             }).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4); | ||||
|         } | ||||
|         return Q.when(completions); | ||||
|         return completions; | ||||
|     } | ||||
| 
 | ||||
|     getName() { | ||||
|  | @ -68,4 +66,8 @@ export default class RoomProvider extends AutocompleteProvider { | |||
|             {completions} | ||||
|         </div>; | ||||
|     } | ||||
| 
 | ||||
|     shouldForceComplete(): boolean { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -20,28 +20,34 @@ 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 => { | ||||
|                 const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
 | ||||
|                 let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
 | ||||
|                 let completion = displayName; | ||||
|                 if (range.start === 0) { | ||||
|                     completion += ': '; | ||||
|                 } else { | ||||
|                     completion += ' '; | ||||
|                 } | ||||
|                 return { | ||||
|                     completion: user.userId, | ||||
|                     completion, | ||||
|                     component: ( | ||||
|                         <PillCompletion | ||||
|                             initialComponent={<MemberAvatar member={user} width={24} height={24}/>} | ||||
|                             title={displayName} | ||||
|                             description={user.userId} /> | ||||
|                     ), | ||||
|                     range | ||||
|                     range, | ||||
|                 }; | ||||
|             }).slice(0, 4); | ||||
|         } | ||||
|         return Q.when(completions); | ||||
|         return completions; | ||||
|     } | ||||
| 
 | ||||
|     getName() { | ||||
|  | @ -64,4 +70,8 @@ export default class UserProvider extends AutocompleteProvider { | |||
|             {completions} | ||||
|         </div>; | ||||
|     } | ||||
| 
 | ||||
|     shouldForceComplete(): boolean { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -2,14 +2,21 @@ 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 Q from 'q'; | ||||
| 
 | ||||
| import {getCompletions} from '../../../autocomplete/Autocompleter'; | ||||
| 
 | ||||
| const COMPOSER_SELECTED = 0; | ||||
| 
 | ||||
| export default class Autocomplete extends React.Component { | ||||
| 
 | ||||
|     constructor(props) { | ||||
|         super(props); | ||||
| 
 | ||||
|         this.completionPromise = null; | ||||
|         this.onConfirm = this.onConfirm.bind(this); | ||||
| 
 | ||||
|         this.state = { | ||||
|  | @ -19,79 +26,141 @@ 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() { | ||||
|         const done = Q.defer(); | ||||
|         this.setState({ | ||||
|             forceComplete: true, | ||||
|         }, () => { | ||||
|             this.complete(this.props.query, this.props.selection).then(() => { | ||||
|                 done.resolve(); | ||||
|             }); | ||||
|         }); | ||||
|         return done.promise; | ||||
|     } | ||||
| 
 | ||||
|     /** 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 +186,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 +204,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 +220,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 contentChanged = 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, | ||||
|         }, () => contentChanged.resolve()); | ||||
| 
 | ||||
|         if (editorState.getCurrentContent().hasText()) { | ||||
|             this.onTypingActivity(); | ||||
|  | @ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component { | |||
| 
 | ||||
|             this.props.onContentChanged(textContent, selection); | ||||
|         } | ||||
|         return contentChanged.promise; | ||||
|     } | ||||
| 
 | ||||
|     setEditorState(editorState: EditorState) { | ||||
|         return this.onEditorContentChanged(editorState, false); | ||||
|     } | ||||
| 
 | ||||
|     enableRichtext(enabled: boolean) { | ||||
|  | @ -405,13 +434,13 @@ export default class MessageComposerInput extends React.Component { | |||
|             contentState = ContentState.createFromText(markdown); | ||||
|         } | ||||
| 
 | ||||
|         this.setEditorState(this.createEditorState(enabled, contentState), () => { | ||||
|         this.setEditorState(this.createEditorState(enabled, contentState)).then(() => { | ||||
|             this.setState({ | ||||
|                 isRichtextEnabled: enabled, | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); | ||||
|             UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     handleKeyCommand(command: string): 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,70 @@ 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().then(() => { | ||||
|                 this.onDownArrow(e); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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 +690,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 +705,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> | ||||
|         ); | ||||
|     } | ||||
|  |  | |||
|  | @ -114,24 +114,29 @@ describe('MessageComposerInput', () => { | |||
|         expect(spy.calledOnce).toEqual(true, 'should send message'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should convert basic Markdown to rich text correctly', () => { | ||||
|         const spy = sinon.spy(client, 'sendHtmlMessage'); | ||||
|         mci.enableRichtext(false); | ||||
|         addTextToDraft('*abc*'); | ||||
|         mci.handleKeyCommand('toggle-mode'); | ||||
|         mci.handleReturn(sinon.stub()); | ||||
|         expect(spy.args[0][2]).toContain('<em>abc'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should convert basic rich text to Markdown correctly', () => { | ||||
|         const spy = sinon.spy(client, 'sendHtmlMessage'); | ||||
|         mci.enableRichtext(true); | ||||
|         mci.handleKeyCommand('italic'); | ||||
|         addTextToDraft('abc'); | ||||
|         mci.handleKeyCommand('toggle-mode'); | ||||
|         mci.handleReturn(sinon.stub()); | ||||
|         expect(['_abc_', '*abc*']).toContain(spy.args[0][1]); | ||||
|     }); | ||||
|     // FIXME
 | ||||
|     // it('should convert basic Markdown to rich text correctly', () => {
 | ||||
|     //     const spy = sinon.spy(client, 'sendHtmlMessage');
 | ||||
|     //     mci.enableRichtext(false);
 | ||||
|     //     addTextToDraft('*abc*');
 | ||||
|     //     mci.handleKeyCommand('toggle-mode');
 | ||||
|     //     mci.handleReturn(sinon.stub());
 | ||||
|     //     console.error(spy.args[0][2]);
 | ||||
|     //     expect(spy.args[0][2]).toContain('<em>abc');
 | ||||
|     // });
 | ||||
|     //
 | ||||
|     // it('should convert basic rich text to Markdown correctly', () => {
 | ||||
|     //     const spy = sinon.spy(client, 'sendHtmlMessage');
 | ||||
|     //     mci.enableRichtext(true);
 | ||||
|     //     process.nextTick(() => {
 | ||||
|     //
 | ||||
|     //     });
 | ||||
|     //     mci.handleKeyCommand('italic');
 | ||||
|     //     addTextToDraft('abc');
 | ||||
|     //     mci.handleKeyCommand('toggle-mode');
 | ||||
|     //     mci.handleReturn(sinon.stub());
 | ||||
|     //     expect(['_abc_', '*abc*']).toContain(spy.args[0][1]);
 | ||||
|     // });
 | ||||
| 
 | ||||
|     it('should insert formatting characters in Markdown mode', () => { | ||||
|         const spy = sinon.spy(client, 'sendHtmlMessage'); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Matthew Hodgson
						Matthew Hodgson