diff --git a/.eslintrc b/.eslintrc index 761fd2af2b..3f6c8e6953 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,7 +36,12 @@ "no-new-wrappers": ["error"], "no-invalid-regexp": ["error"], "no-extra-bind": ["error"], - "no-magic-numbers": ["error"], + "no-magic-numbers": ["error", { + "ignore": [-1, 0, 1], // usually used in array/string indexing + "ignoreArrayIndexes": true, + "enforceConst": true, + "detectObjects": true + }], "consistent-return": ["error"], "valid-jsdoc": ["error"], "no-use-before-define": ["error"], diff --git a/package.json b/package.json index 6c78c92b93..13cabf32d9 100644 --- a/package.json +++ b/package.json @@ -29,21 +29,26 @@ "draft-js-export-html": "^0.2.2", "draft-js-export-markdown": "^0.2.0", "draft-js-import-markdown": "^0.1.6", + "emojione": "^2.2.2", "favico.js": "^0.3.10", "filesize": "^3.1.2", "flux": "^2.0.3", + "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", + "lodash": "^4.13.1", "marked": "^0.3.5", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.0.1", + "react-addons-css-transition-group": "^15.1.0", "react-dom": "^15.0.1", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "sanitize-html": "^1.11.1", - "velocity-vector": "vector-im/velocity#059e3b2" + "velocity-vector": "vector-im/velocity#059e3b2", + "whatwg-fetch": "^1.0.0" }, "//babelversion": [ "brief experiments with babel6 seems to show that it generates source ", diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 143b804228..be925af19b 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -20,7 +20,7 @@ limitations under the License. var Matrix = require("matrix-js-sdk"); var GuestAccess = require("./GuestAccess"); -var matrixClient = null; +let matrixClient: MatrixClient = null; var localStorage = window.localStorage; @@ -87,7 +87,7 @@ class MatrixClient { this.guestAccess = guestAccess; } - get() { + get(): MatrixClient { return matrixClient; } diff --git a/src/RichText.js b/src/RichText.js index 7e749bc24a..c24a510e05 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,13 +1,17 @@ +import React from 'react'; import { Editor, Modifier, ContentState, + ContentBlock, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, - CompositeDecorator + CompositeDecorator, + SelectionState, } from 'draft-js'; import * as sdk from './index'; +import * as emojione from 'emojione'; const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { element: 'span' @@ -23,17 +27,18 @@ const STYLES = { CODE: 'code', ITALIC: 'em', STRIKETHROUGH: 's', - UNDERLINE: 'u' + UNDERLINE: 'u', }; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, ITALIC: /([\*_])([\w\s]+?)\1/g, - BOLD: /([\*_])\1([\w\s]+?)\1\1/g + BOLD: /([\*_])\1([\w\s]+?)\1\1/g, }; const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; +const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); export function contentStateToHTML(contentState: ContentState): string { return contentState.getBlockMap().map((block) => { @@ -88,6 +93,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { return {avatar} {props.children}; } }; + let roomDecorator = { strategy: (contentBlock, callback) => { findWithRegex(ROOM_REGEX, contentBlock, callback); @@ -97,6 +103,16 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } }; + // Unused for now, due to https://github.com/facebook/draft-js/issues/414 + let emojiDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(EMOJI_REGEX, contentBlock, callback); + }, + component: (props) => { + return + } + }; + return [usernameDecorator, roomDecorator]; } @@ -153,7 +169,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection text = ""; - for(let currentKey = startKey; + for (let currentKey = startKey; currentKey && currentKey !== endKey; currentKey = contentState.getKeyAfter(currentKey)) { let blockText = getText(currentKey); @@ -168,3 +184,59 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); } + +/** + * Computes the plaintext offsets of the given SelectionState. + * Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) + * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. + */ +export function selectionStateToTextOffsets(selectionState: SelectionState, + contentBlocks: Array): {start: number, end: number} { + let offset = 0, start = 0, end = 0; + for(let block of contentBlocks) { + if (selectionState.getStartKey() === block.getKey()) { + start = offset + selectionState.getStartOffset(); + } + if (selectionState.getEndKey() === block.getKey()) { + end = offset + selectionState.getEndOffset(); + break; + } + offset += block.getLength(); + } + + return { + start, + end, + }; +} + +export function textOffsetsToSelectionState({start, end}: {start: number, end: number}, + contentBlocks: Array): SelectionState { + let selectionState = SelectionState.createEmpty(); + + for (let block of contentBlocks) { + let blockLength = block.getLength(); + + if (start !== -1 && start < blockLength) { + selectionState = selectionState.merge({ + anchorKey: block.getKey(), + anchorOffset: start, + }); + start = -1; + } else { + start -= blockLength; + } + + if (end !== -1 && end <= blockLength) { + selectionState = selectionState.merge({ + focusKey: block.getKey(), + focusOffset: end, + }); + end = -1; + } else { + end -= blockLength; + } + } + + return selectionState; +} diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js new file mode 100644 index 0000000000..41d5d035d1 --- /dev/null +++ b/src/autocomplete/AutocompleteProvider.js @@ -0,0 +1,54 @@ +import Q from 'q'; + +export default class AutocompleteProvider { + constructor(commandRegex?: RegExp, fuseOpts?: any) { + if(commandRegex) { + if(!commandRegex.global) { + throw new Error('commandRegex must have global flag set'); + } + this.commandRegex = commandRegex; + } + } + + /** + * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. + */ + getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array { + if (this.commandRegex == null) { + return null; + } + + this.commandRegex.lastIndex = 0; + + let match; + while ((match = this.commandRegex.exec(query)) != null) { + let matchStart = match.index, + matchEnd = matchStart + match[0].length; + + if (selection.start <= matchEnd && selection.end >= matchStart) { + return { + command: match, + range: { + start: matchStart, + end: matchEnd, + }, + }; + } + } + return { + command: null, + range: { + start: -1, + end: -1, + }, + }; + } + + getCompletions(query: string, selection: {start: number, end: number}) { + return Q.when([]); + } + + getName(): string { + return 'Default Provider'; + } +} diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js new file mode 100644 index 0000000000..7f32e0ca40 --- /dev/null +++ b/src/autocomplete/Autocompleter.js @@ -0,0 +1,22 @@ +import CommandProvider from './CommandProvider'; +import DuckDuckGoProvider from './DuckDuckGoProvider'; +import RoomProvider from './RoomProvider'; +import UserProvider from './UserProvider'; +import EmojiProvider from './EmojiProvider'; + +const PROVIDERS = [ + UserProvider, + CommandProvider, + DuckDuckGoProvider, + RoomProvider, + EmojiProvider, +].map(completer => completer.getInstance()); + +export function getCompletions(query: string, selection: {start: number, end: number}) { + return PROVIDERS.map(provider => { + return { + completions: provider.getCompletions(query, selection), + provider, + }; + }); +} diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js new file mode 100644 index 0000000000..19a366ac63 --- /dev/null +++ b/src/autocomplete/CommandProvider.js @@ -0,0 +1,86 @@ +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; + +const COMMANDS = [ + { + command: '/me', + args: '', + description: 'Displays action', + }, + { + command: '/ban', + args: ' [reason]', + description: 'Bans user with given id', + }, + { + command: '/deop', + args: '', + description: 'Deops user with given id', + }, + { + command: '/invite', + args: '', + description: 'Invites user with given id to current room' + }, + { + command: '/join', + args: '', + description: 'Joins room with given alias', + }, + { + command: '/kick', + args: ' [reason]', + description: 'Kicks user with given id', + }, + { + command: '/nick', + args: '', + description: 'Changes your display nickname', + }, +]; + +let COMMAND_RE = /(^\/\w*)/g; + +let instance = null; + +export default class CommandProvider extends AutocompleteProvider { + constructor() { + super(COMMAND_RE); + this.fuse = new Fuse(COMMANDS, { + keys: ['command', 'args', 'description'], + }); + } + + getCompletions(query: string, selection: {start: number, end: number}) { + let completions = []; + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { + completions = this.fuse.search(command[0]).map(result => { + return { + completion: result.command + ' ', + component: (), + range, + }; + }); + } + return Q.when(completions); + } + + getName() { + return 'Commands'; + } + + static getInstance(): CommandProvider { + if (instance == null) + instance = new CommandProvider(); + + return instance; + } +} diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js new file mode 100644 index 0000000000..168da00c1c --- /dev/null +++ b/src/autocomplete/Components.js @@ -0,0 +1,19 @@ +import React from 'react'; + +export function TextualCompletion({ + title, + subtitle, + description, +}: { + title: ?string, + subtitle: ?string, + description: ?string +}) { + return ( +
+ {title} + {subtitle} + {description} +
+ ); +} diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js new file mode 100644 index 0000000000..cfd3cb2ff6 --- /dev/null +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -0,0 +1,90 @@ +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import 'whatwg-fetch'; + +import {TextualCompletion} from './Components'; + +const DDG_REGEX = /\/ddg\s+(.+)$/g; +const REFERRER = 'vector'; + +let instance = null; + +export default class DuckDuckGoProvider extends AutocompleteProvider { + constructor() { + super(DDG_REGEX); + } + + static getQueryUri(query: String) { + return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` + + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; + } + + getCompletions(query: string, selection: {start: number, end: number}) { + let {command, range} = this.getCurrentCommand(query, selection); + if (!query || !command) { + return Q.when([]); + } + + return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { + method: 'GET', + }) + .then(response => response.json()) + .then(json => { + let results = json.Results.map(result => { + return { + completion: result.Text, + component: ( + + ), + range, + }; + }); + if (json.Answer) { + results.unshift({ + completion: json.Answer, + component: ( + + ), + range, + }); + } + if (json.RelatedTopics && json.RelatedTopics.length > 0) { + results.unshift({ + completion: json.RelatedTopics[0].Text, + component: ( + + ), + range, + }); + } + if (json.AbstractText) { + results.unshift({ + completion: json.AbstractText, + component: ( + + ), + range, + }); + } + return results; + }); + } + + getName() { + return 'Results from DuckDuckGo'; + } + + static getInstance(): DuckDuckGoProvider { + if (instance == null) { + instance = new DuckDuckGoProvider(); + } + return instance; + } +} diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js new file mode 100644 index 0000000000..574144e95b --- /dev/null +++ b/src/autocomplete/EmojiProvider.js @@ -0,0 +1,48 @@ +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; +import Fuse from 'fuse.js'; + +const EMOJI_REGEX = /:\w*:?/g; +const EMOJI_SHORTNAMES = Object.keys(emojioneList); + +let instance = null; + +export default class EmojiProvider extends AutocompleteProvider { + constructor() { + super(EMOJI_REGEX); + this.fuse = new Fuse(EMOJI_SHORTNAMES); + } + + getCompletions(query: string, selection: {start: number, end: number}) { + let completions = []; + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { + completions = this.fuse.search(command[0]).map(result => { + let shortname = EMOJI_SHORTNAMES[result]; + let imageHTML = shortnameToImage(shortname); + return { + completion: shortnameToUnicode(shortname), + component: ( +
+ {shortname} +
+ ), + range, + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Emoji'; + } + + static getInstance() { + if (instance == null) + instance = new EmojiProvider(); + return instance; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js new file mode 100644 index 0000000000..b34fdeb59a --- /dev/null +++ b/src/autocomplete/RoomProvider.js @@ -0,0 +1,62 @@ +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import MatrixClientPeg from '../MatrixClientPeg'; +import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; +import {getDisplayAliasForRoom} from '../MatrixTools'; + +const ROOM_REGEX = /(?=#)([^\s]*)/g; + +let instance = null; + +export default class RoomProvider extends AutocompleteProvider { + constructor() { + super(ROOM_REGEX, { + keys: ['displayName', 'userId'], + }); + this.fuse = new Fuse([], { + keys: ['name', 'roomId', 'aliases'], + }); + } + + getCompletions(query: string, selection: {start: number, end: number}) { + let client = MatrixClientPeg.get(); + let completions = []; + const {command, range} = this.getCurrentCommand(query, selection); + if (command) { + // the only reason we need to do this is because Fuse only matches on properties + this.fuse.set(client.getRooms().filter(room => !!room).map(room => { + return { + room: room, + name: room.name, + roomId: room.roomId, + aliases: room.getAliases(), + }; + })); + completions = this.fuse.search(command[0]).map(room => { + let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; + return { + completion: displayAlias, + component: ( + + ), + range, + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Rooms'; + } + + static getInstance() { + if (instance == null) { + instance = new RoomProvider(); + } + + return instance; + } +} diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js new file mode 100644 index 0000000000..3e65a65676 --- /dev/null +++ b/src/autocomplete/UserProvider.js @@ -0,0 +1,55 @@ +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; + +const USER_REGEX = /@[^\s]*/g; + +let instance = null; + +export default class UserProvider extends AutocompleteProvider { + constructor() { + super(USER_REGEX, { + keys: ['displayName', 'userId'], + }); + this.users = []; + this.fuse = new Fuse([], { + keys: ['displayName', 'userId'], + }); + } + + getCompletions(query: string, selection: {start: number, end: number}) { + let completions = []; + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { + this.fuse.set(this.users); + completions = this.fuse.search(command[0]).map(user => { + return { + completion: user.userId, + component: ( + + ), + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Users'; + } + + setUserList(users) { + this.users = users; + } + + static getInstance(): UserProvider { + if (instance == null) { + instance = new UserProvider(); + } + return instance; + } +} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7946f723e8..dc4b21a300 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -41,6 +41,8 @@ var rate_limited_func = require('../../ratelimitedfunc'); var ObjectUtils = require('../../ObjectUtils'); var MatrixTools = require('../../MatrixTools'); +import UserProvider from '../../autocomplete/UserProvider'; + var DEBUG = false; if (DEBUG) { @@ -516,21 +518,23 @@ module.exports = React.createClass({ } }, - _updateTabCompleteList: new rate_limited_func(function() { + _updateTabCompleteList: function() { var cli = MatrixClientPeg.get(); - if (!this.state.room || !this.tabComplete) { + if (!this.state.room) { return; } var members = this.state.room.getJoinedMembers().filter(function(member) { if (member.userId !== cli.credentials.userId) return true; }); + + UserProvider.getInstance().setUserList(members); this.tabComplete.setCompletionList( MemberEntry.fromMemberList(members).concat( CommandEntry.fromCommands(SlashCommands.getCommandList()) ) ); - }, 500), + }, componentDidUpdate: function() { if (this.refs.roomView) { diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js new file mode 100644 index 0000000000..95133778ba --- /dev/null +++ b/src/components/views/rooms/Autocomplete.js @@ -0,0 +1,158 @@ +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import classNames from 'classnames'; +import flatMap from 'lodash/flatMap'; + +import {getCompletions} from '../../../autocomplete/Autocompleter'; + +export default class Autocomplete extends React.Component { + constructor(props) { + super(props); + + this.onConfirm = this.onConfirm.bind(this); + + this.state = { + // list of completionResults, each containing completions + completions: [], + + // array of completions, so we can look up current selection by offset quickly + completionList: [], + + // how far down the completion list we are + selectionOffset: 0, + }; + } + + componentWillReceiveProps(props, state) { + if (props.query === this.props.query) { + return; + } + + getCompletions(props.query, props.selection).forEach(completionResult => { + try { + completionResult.completions.then(completions => { + let i = this.state.completions.findIndex( + completion => completion.provider === completionResult.provider + ); + + i = i === -1 ? this.state.completions.length : i; + let newCompletions = Object.assign([], this.state.completions); + completionResult.completions = completions; + newCompletions[i] = completionResult; + + this.setState({ + completions: newCompletions, + completionList: flatMap(newCompletions, provider => provider.completions), + }); + }, err => { + console.error(err); + }); + } catch (e) { + // An error in one provider shouldn't mess up the rest. + console.error(e); + } + }); + } + + countCompletions(): number { + return this.state.completions.map(completionResult => { + return completionResult.completions.length; + }).reduce((l, r) => l + r); + } + + // called from MessageComposerInput + onUpArrow(): boolean { + let completionCount = this.countCompletions(), + selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; + this.setSelection(selectionOffset); + return true; + } + + // called from MessageComposerInput + onDownArrow(): boolean { + let completionCount = this.countCompletions(), + selectionOffset = (this.state.selectionOffset + 1) % completionCount; + this.setSelection(selectionOffset); + return true; + } + + /** called from MessageComposerInput + * @returns {boolean} whether confirmation was handled + */ + onConfirm(): boolean { + if (this.countCompletions() === 0) { + return false; + } + + let selectedCompletion = this.state.completionList[this.state.selectionOffset]; + this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion); + + return true; + } + + setSelection(selectionOffset: number) { + this.setState({selectionOffset}); + } + + render() { + let position = 0; + let renderedCompletions = this.state.completions.map((completionResult, i) => { + let completions = completionResult.completions.map((completion, i) => { + let className = classNames('mx_Autocomplete_Completion', { + 'selected': position === this.state.selectionOffset, + }); + let componentPosition = position; + position++; + + let onMouseOver = () => this.setSelection(componentPosition); + let onClick = () => { + this.setSelection(componentPosition); + this.onConfirm(); + }; + + return ( +
+ {completion.component} +
+ ); + }); + + + return completions.length > 0 ? ( +
+ {completionResult.provider.getName()} + + {completions} + +
+ ) : null; + }); + + return ( +
+ + {renderedCompletions} + +
+ ); + } +} + +Autocomplete.propTypes = { + // the query string for which to show autocomplete suggestions + query: React.PropTypes.string.isRequired, + + // method invoked with range and text content when completion is confirmed + onConfirm: React.PropTypes.func.isRequired, +}; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index eaee8205e4..4dc28e73c5 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -20,54 +20,52 @@ var MatrixClientPeg = require('../../../MatrixClientPeg'); var Modal = require('../../../Modal'); var sdk = require('../../../index'); var dis = require('../../../dispatcher'); +import Autocomplete from './Autocomplete'; import UserSettingsStore from '../../../UserSettingsStore'; -module.exports = React.createClass({ - displayName: 'MessageComposer', +export default class MessageComposer extends React.Component { + constructor(props, context) { + super(props, context); + this.onCallClick = this.onCallClick.bind(this); + this.onHangupClick = this.onHangupClick.bind(this); + this.onUploadClick = this.onUploadClick.bind(this); + this.onUploadFileSelected = this.onUploadFileSelected.bind(this); + this.onVoiceCallClick = this.onVoiceCallClick.bind(this); + this.onInputContentChanged = this.onInputContentChanged.bind(this); + this.onUpArrow = this.onUpArrow.bind(this); + this.onDownArrow = this.onDownArrow.bind(this); + this.onTab = this.onTab.bind(this); - propTypes: { - tabComplete: React.PropTypes.any, + this.state = { + autocompleteQuery: '', + selection: null, + }; - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: React.PropTypes.func, + } - // js-sdk Room object - room: React.PropTypes.object.isRequired, - - // string representing the current voip call state - callState: React.PropTypes.string, - - // callback when a file to upload is chosen - uploadFile: React.PropTypes.func.isRequired, - - // opacity for dynamic UI fading effects - opacity: React.PropTypes.number, - }, - - onUploadClick: function(ev) { + onUploadClick(ev) { if (MatrixClientPeg.get().isGuest()) { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); + let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { title: "Please Register", - description: "Guest users can't upload files. Please register to upload." + description: "Guest users can't upload files. Please register to upload.", }); return; } this.refs.uploadInput.click(); - }, + } - onUploadFileSelected: function(ev) { - var files = ev.target.files; + onUploadFileSelected(ev) { + let files = ev.target.files; - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + let TintableSvg = sdk.getComponent("elements.TintableSvg"); - var fileList = []; - for(var i=0; i {files[i].name} ); @@ -94,11 +92,11 @@ module.exports = React.createClass({ } this.refs.uploadInput.value = null; - } + }, }); - }, + } - onHangupClick: function() { + onHangupClick() { var call = CallHandler.getCallForRoom(this.props.room.roomId); //var call = CallHandler.getAnyActiveCall(); if (!call) { @@ -108,27 +106,46 @@ module.exports = React.createClass({ action: 'hangup', // hangup the call for this room, which may not be the room in props // (e.g. conferences which will hangup the 1:1 room instead) - room_id: call.roomId + room_id: call.roomId, }); - }, + } - onCallClick: function(ev) { + onCallClick(ev) { dis.dispatch({ action: 'place_call', type: ev.shiftKey ? "screensharing" : "video", - room_id: this.props.room.roomId + room_id: this.props.room.roomId, }); - }, + } - onVoiceCallClick: function(ev) { + onVoiceCallClick(ev) { dis.dispatch({ action: 'place_call', type: 'voice', - room_id: this.props.room.roomId + room_id: this.props.room.roomId, }); - }, + } - render: function() { + onInputContentChanged(content: string, selection: {start: number, end: number}) { + this.setState({ + autocompleteQuery: content, + selection, + }); + } + + onUpArrow() { + return this.refs.autocomplete.onUpArrow(); + } + + onDownArrow() { + return this.refs.autocomplete.onDownArrow(); + } + + onTab() { + return this.refs.autocomplete.onTab(); + } + + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); @@ -155,11 +172,11 @@ module.exports = React.createClass({ callButton =
-
+ ; videoCallButton =
-
+ ; } var canSendMessages = this.props.room.currentState.maySendMessage( @@ -181,8 +198,16 @@ module.exports = React.createClass({ ); controls.push( - , + this.messageComposerInput = c} + key="controls_input" + onResize={this.props.onResize} + room={this.props.room} + tryComplete={this.refs.autocomplete && this.refs.autocomplete.onConfirm} + onUpArrow={this.onUpArrow} + onDownArrow={this.onDownArrow} + onTab={this.onTab} + onContentChanged={this.onInputContentChanged} />, uploadButton, hangupButton, callButton, @@ -198,6 +223,13 @@ module.exports = React.createClass({ return (
+
+ +
{controls} @@ -206,5 +238,24 @@ module.exports = React.createClass({
); } -}); +}; +MessageComposer.propTypes = { + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, + + // js-sdk Room object + room: React.PropTypes.object.isRequired, + + // string representing the current voip call state + callState: React.PropTypes.string, + + // callback when a file to upload is chosen + uploadFile: React.PropTypes.func.isRequired, + + // opacity for dynamic UI fading effects + opacity: React.PropTypes.number +}; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 8a0ee7d8a8..46abc20ed6 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -72,7 +72,11 @@ export default class MessageComposerInput extends React.Component { this.onInputClick = this.onInputClick.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); - this.onChange = this.onChange.bind(this); + this.setEditorState = this.setEditorState.bind(this); + this.onUpArrow = this.onUpArrow.bind(this); + this.onDownArrow = this.onDownArrow.bind(this); + this.onTab = this.onTab.bind(this); + this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); if(isRichtextEnabled == null) { @@ -82,7 +86,7 @@ export default class MessageComposerInput extends React.Component { this.state = { isRichtextEnabled: isRichtextEnabled, - editorState: null + editorState: null, }; // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled @@ -93,7 +97,7 @@ export default class MessageComposerInput extends React.Component { static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes - if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { + if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { return 'toggle-mode'; } @@ -207,11 +211,9 @@ export default class MessageComposerInput extends React.Component { let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); if (contentJSON) { let content = convertFromRaw(JSON.parse(contentJSON)); - component.setState({ - editorState: component.createEditorState(component.state.isRichtextEnabled, content) - }); + component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); } - } + }, }; } @@ -233,7 +235,7 @@ export default class MessageComposerInput extends React.Component { } onAction(payload) { - var editor = this.refs.editor; + let editor = this.refs.editor; switch (payload.action) { case 'focus_composer': @@ -251,7 +253,7 @@ export default class MessageComposerInput extends React.Component { payload.displayname ); this.setState({ - editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters') + editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), }); editor.focus(); } @@ -344,7 +346,7 @@ export default class MessageComposerInput extends React.Component { this.refs.editor.focus(); } - onChange(editorState: EditorState) { + setEditorState(editorState: EditorState) { this.setState({editorState}); if(editorState.getCurrentContent().hasText()) { @@ -352,20 +354,22 @@ export default class MessageComposerInput extends React.Component { } else { this.onFinishedTyping(); } + + if(this.props.onContentChanged) { + this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), + RichText.selectionStateToTextOffsets(editorState.getSelection(), + editorState.getCurrentContent().getBlocksAsArray())); + } } enableRichtext(enabled: boolean) { if (enabled) { let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); - this.setState({ - editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html)) - }); + this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html))); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), contentState = ContentState.createFromText(markdown); - this.setState({ - editorState: this.createEditorState(enabled, contentState) - }); + this.setEditorState(this.createEditorState(enabled, contentState)); } window.localStorage.setItem('mx_editor_rte_enabled', enabled); @@ -408,19 +412,28 @@ export default class MessageComposerInput extends React.Component { newState = RichUtils.handleKeyCommand(this.state.editorState, command); if (newState != null) { - this.onChange(newState); + this.setEditorState(newState); return true; } return false; } handleReturn(ev) { - if(ev.shiftKey) + if (ev.shiftKey) { return false; + } + + if(this.props.tryComplete) { + if(this.props.tryComplete()) { + return true; + } + } const contentState = this.state.editorState.getCurrentContent(); - if(!contentState.hasText()) + if (!contentState.hasText()) { return true; + } + let contentText = contentState.getPlainText(), contentHTML; @@ -489,10 +502,49 @@ export default class MessageComposerInput extends React.Component { return true; } + onUpArrow(e) { + if(this.props.onUpArrow) { + if(this.props.onUpArrow()) { + e.preventDefault(); + } + } + } + + onDownArrow(e) { + if(this.props.onDownArrow) { + if(this.props.onDownArrow()) { + e.preventDefault(); + } + } + } + + onTab(e) { + if (this.props.onTab) { + if (this.props.onTab()) { + e.preventDefault(); + } + } + } + + onConfirmAutocompletion(range, content: string) { + let contentState = Modifier.replaceText( + this.state.editorState.getCurrentContent(), + RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()), + content + ); + + this.setState({ + editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), + }); + + // for some reason, doing this right away does not update the editor :( + setTimeout(() => this.refs.editor.focus(), 50); + } + render() { let className = "mx_MessageComposer_input"; - if(this.state.isRichtextEnabled) { + if (this.state.isRichtextEnabled) { className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode } @@ -502,11 +554,14 @@ export default class MessageComposerInput extends React.Component {
); @@ -521,5 +576,14 @@ MessageComposerInput.propTypes = { onResize: React.PropTypes.func, // js-sdk Room object - room: React.PropTypes.object.isRequired + room: React.PropTypes.object.isRequired, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func, + + onUpArrow: React.PropTypes.func, + + onDownArrow: React.PropTypes.func, + + onTab: React.PropTypes.func };