diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..5008ddfcf5 Binary files /dev/null and b/.DS_Store differ diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 796c1ed58d..fd18b22d30 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -104,19 +104,25 @@ class ContentMessages { var def = q.defer(); if (file.type.indexOf('image/') == 0) { content.msgtype = 'm.image'; - infoForImageFile(file).then(function (imageInfo) { + infoForImageFile(file).then(imageInfo=>{ extend(content.info, imageInfo); def.resolve(); + }, error=>{ + content.msgtype = 'm.file'; + def.resolve(); }); } else if (file.type.indexOf('audio/') == 0) { content.msgtype = 'm.audio'; def.resolve(); } else if (file.type.indexOf('video/') == 0) { - content.msgtype = 'm.video'; - infoForVideoFile(file).then(function (videoInfo) { - extend(content.info, videoInfo); - def.resolve(); - }); + content.msgtype = 'm.video'; + infoForVideoFile(file).then(videoInfo=>{ + extend(content.info, videoInfo); + def.resolve(); + }, error=>{ + content.msgtype = 'm.file'; + def.resolve(); + }); } else { content.msgtype = 'm.file'; def.resolve(); diff --git a/src/RichText.js b/src/RichText.js index c24a510e05..7cd78a14c9 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,6 +1,7 @@ import React from 'react'; import { Editor, + EditorState, Modifier, ContentState, ContentBlock, @@ -9,12 +10,13 @@ import { DefaultDraftInlineStyle, CompositeDecorator, SelectionState, + Entity, } from 'draft-js'; import * as sdk from './index'; import * as emojione from 'emojione'; const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { - element: 'span' + element: 'span', /* draft uses
by default which we don't really like, so we're using this is probably not a good idea since is not a block level element but @@ -65,7 +67,7 @@ export function contentStateToHTML(contentState: ContentState): string { let result = `<${elem}>${content.join('')}`; // dirty hack because we don't want block level tags by default, but breaks - if(elem === 'span') + if (elem === 'span') result += '
'; return result; }).join(''); @@ -75,6 +77,48 @@ export function HTMLtoContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } +function unicodeToEmojiUri(str) { + let replaceWith, unicode, alt; + if ((!emojione.unicodeAlt) || (emojione.sprites)) { + // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames + let mappedUnicode = emojione.mapUnicodeToShort(); + } + + str = str.replace(emojione.regUnicode, function(unicodeChar) { + if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) { + // if the unicodeChar doesnt exist just return the entire match + return unicodeChar; + } else { + // get the unicode codepoint from the actual char + unicode = emojione.jsEscapeMap[unicodeChar]; + return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; + } + }); + + return str; +} + +// Workaround for https://github.com/facebook/draft-js/issues/414 +let emojiDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(EMOJI_REGEX, contentBlock, callback); + }, + component: (props) => { + let uri = unicodeToEmojiUri(props.children[0].props.text); + let shortname = emojione.toShort(props.children[0].props.text); + let style = { + display: 'inline-block', + width: '1em', + maxHeight: '1em', + background: `url(${uri})`, + backgroundSize: 'contain', + backgroundPosition: 'center center', + overflow: 'hidden', + }; + return ({props.children}); + }, +}; + /** * Returns a composite decorator which has access to provided scope. */ @@ -90,7 +134,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { // unused until we make these decorators immutable (autocomplete needed) let name = member ? member.name : null; let avatar = member ? : null; - return {avatar} {props.children}; + return {avatar}{props.children}; } }; @@ -103,17 +147,7 @@ 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]; + return [usernameDecorator, roomDecorator, emojiDecorator]; } export function getScopedMDDecorators(scope: any): CompositeDecorator { @@ -139,6 +173,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { ) }); + markdownDecorators.push(emojiDecorator); return markdownDecorators; } @@ -193,7 +228,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection export function selectionStateToTextOffsets(selectionState: SelectionState, contentBlocks: Array): {start: number, end: number} { let offset = 0, start = 0, end = 0; - for(let block of contentBlocks) { + for (let block of contentBlocks) { if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } @@ -240,3 +275,50 @@ export function textOffsetsToSelectionState({start, end}: {start: number, end: n return selectionState; } + +// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js +export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState { + const contentState = editorState.getCurrentContent(); + const blocks = contentState.getBlockMap(); + let newContentState = contentState; + + blocks.forEach((block) => { + const plainText = block.getText(); + + const addEntityToEmoji = (start, end) => { + const existingEntityKey = block.getEntityAt(start); + if (existingEntityKey) { + // avoid manipulation in case the emoji already has an entity + const entity = Entity.get(existingEntityKey); + if (entity && entity.get('type') === 'emoji') { + return; + } + } + + const selection = SelectionState.createEmpty(block.getKey()) + .set('anchorOffset', start) + .set('focusOffset', end); + const emojiText = plainText.substring(start, end); + const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); + newContentState = Modifier.replaceText( + newContentState, + selection, + emojiText, + null, + entityKey, + ); + }; + + findWithRegex(EMOJI_REGEX, block, addEntityToEmoji); + }); + + if (!newContentState.equals(contentState)) { + return EditorState.push( + editorState, + newContentState, + 'convert-to-immutable-emojis', + ); + } + + return editorState; +} diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index a583592bae..8828f8cb70 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -11,11 +11,11 @@ let instance = null; export default class UserProvider extends AutocompleteProvider { constructor() { super(USER_REGEX, { - keys: ['displayName', 'userId'], + keys: ['name', 'userId'], }); this.users = []; this.fuse = new Fuse([], { - keys: ['displayName', 'userId'], + keys: ['name', 'userId'], }); } @@ -25,11 +25,12 @@ export default class UserProvider extends AutocompleteProvider { 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 return { completion: user.userId, component: ( ), range diff --git a/src/component-index.js b/src/component-index.js index 95293c2990..97f8882b82 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -25,6 +25,7 @@ limitations under the License. */ module.exports.components = {}; +module.exports.components['structures.ContextualMenu'] = require('./components/structures/ContextualMenu'); module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel'); diff --git a/src/ContextualMenu.js b/src/components/structures/ContextualMenu.js similarity index 63% rename from src/ContextualMenu.js rename to src/components/structures/ContextualMenu.js index e720b69eda..fcfc5d5e50 100644 --- a/src/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; +var classNames = require('classnames'); var React = require('react'); var ReactDOM = require('react-dom'); @@ -27,6 +28,12 @@ var ReactDOM = require('react-dom'); module.exports = { ContextualMenuContainerId: "mx_ContextualMenu_Container", + propTypes: { + menuWidth: React.PropTypes.number, + menuHeight: React.PropTypes.number, + chevronOffset: React.PropTypes.number, + }, + getOrCreateContainer: function() { var container = document.getElementById(this.ContextualMenuContainerId); @@ -45,29 +52,50 @@ module.exports = { var closeMenu = function() { ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); - if (props && props.onFinished) props.onFinished.apply(null, arguments); + if (props && props.onFinished) { + props.onFinished.apply(null, arguments); + } }; var position = { - top: props.top - 20, + top: props.top, }; + var chevronOffset = { + top: props.chevronOffset, + } + var chevron = null; if (props.left) { - chevron = - position.left = props.left + 8; + chevron =
+ position.left = props.left; } else { - chevron = - position.right = props.right + 8; + chevron =
+ position.right = props.right; } var className = 'mx_ContextualMenu_wrapper'; + var menuClasses = classNames({ + 'mx_ContextualMenu': true, + 'mx_ContextualMenu_left': props.left, + 'mx_ContextualMenu_right': !props.left, + }); + + var menuSize = {}; + if (props.menuWidth) { + menuSize.width = props.menuWidth; + } + + if (props.menuHeight) { + menuSize.height = props.menuHeight; + } + // FIXME: If a menu uses getDefaultProps it clobbers the onFinished // property set here so you can't close the menu from a button click! var menu = ( -
-
+
+
{chevron}
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index b712445dc2..33e6499e21 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -20,7 +20,7 @@ var Favico = require('favico.js'); var MatrixClientPeg = require("../../MatrixClientPeg"); var Notifier = require("../../Notifier"); -var ContextualMenu = require("../../ContextualMenu"); +var ContextualMenu = require("./ContextualMenu"); var RoomListSorter = require("../../RoomListSorter"); var UserActivity = require("../../UserActivity"); var Presence = require("../../Presence"); diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 95133778ba..32e568e2ba 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -64,6 +64,9 @@ export default class Autocomplete extends React.Component { onUpArrow(): boolean { let completionCount = this.countCompletions(), selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; + if (!completionCount) { + return false; + } this.setSelection(selectionOffset); return true; } @@ -72,6 +75,9 @@ export default class Autocomplete extends React.Component { onDownArrow(): boolean { let completionCount = this.countCompletions(), selectionOffset = (this.state.selectionOffset + 1) % completionCount; + if (!completionCount) { + return false; + } this.setSelection(selectionOffset); return true; } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 77be8226a2..7945debd1a 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -23,7 +23,7 @@ var sdk = require('../../../index'); var MatrixClientPeg = require('../../../MatrixClientPeg') var TextForEvent = require('../../../TextForEvent'); -var ContextualMenu = require('../../../ContextualMenu'); +var ContextualMenu = require('../../structures/ContextualMenu'); var dispatcher = require("../../../dispatcher"); var ObjectUtils = require('../../../ObjectUtils'); @@ -249,12 +249,15 @@ module.exports = React.createClass({ }, onEditClicked: function(e) { - var MessageContextMenu = sdk.getComponent('rooms.MessageContextMenu'); + var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); var buttonRect = e.target.getBoundingClientRect() - var x = buttonRect.right; - var y = buttonRect.top + (e.target.height / 2); + + // The window X and Y offsets are to adjust position when zoomed in to page + var x = buttonRect.right + window.pageXOffset; + var y = (buttonRect.top + (e.target.height / 2) + window.pageYOffset) - 19; var self = this; ContextualMenu.createMenu(MessageContextMenu, { + chevronOffset: 10, mxEvent: this.props.mxEvent, left: x, top: y, diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index b7ddf9b2ce..7e684c89a2 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -97,9 +97,11 @@ module.exports = React.createClass({ ); } + var deviceName = this.props.device.display_name || this.props.device.id; + return (
-
{this.props.device.id}
+
{deviceName}
{indicator} {verifyButton} {blockButton} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 282f7f013f..edd8ed7b9a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -36,7 +36,6 @@ export default class MessageComposer extends React.Component { this.onInputContentChanged = this.onInputContentChanged.bind(this); this.onUpArrow = this.onUpArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this); - this.onTab = this.onTab.bind(this); this._tryComplete = this._tryComplete.bind(this); this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); @@ -143,12 +142,6 @@ export default class MessageComposer extends React.Component { return this.refs.autocomplete.onDownArrow(); } - onTab() { - // FIXME Autocomplete doesn't have an onTab - what is this supposed to do? - // return this.refs.autocomplete.onTab(); - return false; - } - _tryComplete(): boolean { if (this.refs.autocomplete) { return this.refs.autocomplete.onConfirm(); @@ -223,7 +216,6 @@ export default class MessageComposer extends React.Component { tryComplete={this._tryComplete} onUpArrow={this.onUpArrow} onDownArrow={this.onDownArrow} - onTab={this.onTab} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete onContentChanged={this.onInputContentChanged} />, uploadButton, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 46abc20ed6..2d42b65246 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; - -var marked = require("marked"); +import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; +import marked from 'marked'; marked.setOptions({ renderer: new marked.Renderer(), gfm: true, @@ -24,7 +24,7 @@ marked.setOptions({ pedantic: false, sanitize: true, smartLists: true, - smartypants: false + smartypants: false, }); import {Editor, EditorState, RichUtils, CompositeDecorator, @@ -33,14 +33,14 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, import {stateToMarkdown} from 'draft-js-export-markdown'; -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var SlashCommands = require("../../../SlashCommands"); -var Modal = require("../../../Modal"); -var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; -var sdk = require('../../../index'); +import MatrixClientPeg from '../../../MatrixClientPeg'; +import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; +import SlashCommands from '../../../SlashCommands'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; -var dis = require("../../../dispatcher"); -var KeyCode = require("../../../KeyCode"); +import dis from '../../../dispatcher'; +import KeyCode from '../../../KeyCode'; import * as RichText from '../../../RichText'; @@ -49,8 +49,8 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const KEY_M = 77; // FIXME Breaks markdown with multiple paragraphs, since it only strips first and last

-function mdownToHtml(mdown) { - var html = marked(mdown) || ""; +function mdownToHtml(mdown: string): string { + let html = marked(mdown) || ""; html = html.trim(); // strip start and end

tags else you get 'orrible spacing if (html.indexOf("

") === 0) { @@ -66,6 +66,17 @@ function mdownToHtml(mdown) { * The textInput part of the MessageComposer */ 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)) { + return 'toggle-mode'; + } + + return getDefaultKeyBinding(e); + } + + client: MatrixClient; + constructor(props, context) { super(props, context); this.onAction = this.onAction.bind(this); @@ -79,7 +90,7 @@ export default class MessageComposerInput extends React.Component { this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); - if(isRichtextEnabled == null) { + if (isRichtextEnabled == null) { isRichtextEnabled = 'true'; } isRichtextEnabled = isRichtextEnabled === 'true'; @@ -95,15 +106,6 @@ export default class MessageComposerInput extends React.Component { this.client = MatrixClientPeg.get(); } - static getKeyBinding(e: SyntheticKeyboardEvent): string { - // C-m => Toggles between rich text and markdown modes - if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { - return 'toggle-mode'; - } - - return getDefaultKeyBinding(e); - } - /** * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled @@ -347,15 +349,16 @@ export default class MessageComposerInput extends React.Component { } setEditorState(editorState: EditorState) { + editorState = RichText.attachImmutableEntitiesToEmoji(editorState); this.setState({editorState}); - if(editorState.getCurrentContent().hasText()) { - this.onTypingActivity() + if (editorState.getCurrentContent().hasText()) { + this.onTypingActivity(); } else { this.onFinishedTyping(); } - if(this.props.onContentChanged) { + if (this.props.onContentChanged) { this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), RichText.selectionStateToTextOffsets(editorState.getSelection(), editorState.getCurrentContent().getBlocksAsArray())); @@ -380,7 +383,7 @@ export default class MessageComposerInput extends React.Component { } handleKeyCommand(command: string): boolean { - if(command === 'toggle-mode') { + if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; } @@ -388,7 +391,7 @@ export default class MessageComposerInput extends React.Component { let newState: ?EditorState = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. - if(!this.state.isRichtextEnabled) { + if (!this.state.isRichtextEnabled) { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); @@ -396,10 +399,10 @@ export default class MessageComposerInput extends React.Component { bold: text => `**${text}**`, italic: text => `*${text}*`, underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - code: text => `\`${text}\`` + code: text => `\`${text}\``, }[command]; - if(modifyFn) { + if (modifyFn) { newState = EditorState.push( this.state.editorState, RichText.modifyText(contentState, selection, modifyFn), @@ -408,7 +411,7 @@ export default class MessageComposerInput extends React.Component { } } - if(newState == null) + if (newState == null) newState = RichUtils.handleKeyCommand(this.state.editorState, command); if (newState != null) { @@ -422,12 +425,6 @@ export default class MessageComposerInput extends React.Component { if (ev.shiftKey) { return false; } - - if(this.props.tryComplete) { - if(this.props.tryComplete()) { - return true; - } - } const contentState = this.state.editorState.getCurrentContent(); if (!contentState.hasText()) { @@ -503,24 +500,20 @@ export default class MessageComposerInput extends React.Component { } onUpArrow(e) { - if(this.props.onUpArrow) { - if(this.props.onUpArrow()) { - e.preventDefault(); - } + if (this.props.onUpArrow && this.props.onUpArrow()) { + e.preventDefault(); } } onDownArrow(e) { - if(this.props.onDownArrow) { - if(this.props.onDownArrow()) { - e.preventDefault(); - } + if (this.props.onDownArrow && this.props.onDownArrow()) { + e.preventDefault(); } } onTab(e) { - if (this.props.onTab) { - if (this.props.onTab()) { + if (this.props.tryComplete) { + if (this.props.tryComplete()) { e.preventDefault(); } } @@ -533,9 +526,11 @@ export default class MessageComposerInput extends React.Component { content ); - this.setState({ - editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), - }); + let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + + editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); + + this.setEditorState(editorState); // for some reason, doing this right away does not update the editor :( setTimeout(() => this.refs.editor.focus(), 50); @@ -585,5 +580,6 @@ MessageComposerInput.propTypes = { onDownArrow: React.PropTypes.func, - onTab: React.PropTypes.func + // attempts to confirm currently selected completion, returns whether actually confirmed + tryComplete: React.PropTypes.func, }; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 0c8ac7ed8d..8e57ceab9b 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -268,9 +268,11 @@ module.exports = React.createClass({ }, _repositionTooltip: function(e) { - if (this.tooltip && this.tooltip.parentElement) { + // We access the parent of the parent, as the tooltip is inside a container + // Needs refactoring into a better multipurpose tooltip + if (this.tooltip && this.tooltip.parentElement && this.tooltip.parentElement.parentElement) { var scroll = ReactDOM.findDOMNode(this); - this.tooltip.style.top = (70 + scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px"; + this.tooltip.style.top = (3 + scroll.parentElement.offsetTop + this.tooltip.parentElement.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px"; } }, diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index aa83110632..602ed4ee04 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -21,6 +21,7 @@ var classNames = require('classnames'); var dis = require("../../../dispatcher"); var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require('../../../index'); +var ContextualMenu = require('../../structures/ContextualMenu'); import {emojifyText} from '../../../HtmlUtils'; module.exports = React.createClass({ @@ -43,16 +44,48 @@ module.exports = React.createClass({ }, getInitialState: function() { + var areNotifsMuted = false; + var cli = MatrixClientPeg.get(); + if (!cli.isGuest()) { + var roomPushRule = cli.getRoomPushRule("global", this.props.room.roomId); + if (roomPushRule) { + if (0 <= roomPushRule.actions.indexOf("dont_notify")) { + areNotifsMuted = true; + } + } + } + return({ hover : false, badgeHover : false, + menu: false, + areNotifsMuted: areNotifsMuted, }); }, + onAction: function(payload) { + switch (payload.action) { + case 'notification_change': + // Is the notification about this room? + if (payload.roomId === this.props.room.roomId) { + this.setState( { areNotifsMuted : payload.isMuted }); + } + break; + } + }, + + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + onClick: function() { dis.dispatch({ action: 'view_room', - room_id: this.props.room.roomId + room_id: this.props.room.roomId, }); }, @@ -65,13 +98,47 @@ module.exports = React.createClass({ }, badgeOnMouseEnter: function() { - this.setState( { badgeHover : true } ); + // Only allow none guests to access the context menu + // and only change it if it needs to change + if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) { + this.setState( { badgeHover : true } ); + } }, badgeOnMouseLeave: function() { this.setState( { badgeHover : false } ); }, + onBadgeClicked: function(e) { + // Only allow none guests to access the context menu + if (!MatrixClientPeg.get().isGuest()) { + + // If the badge is clicked, then no longer show tooltip + if (this.props.collapsed) { + this.setState({ hover: false }); + } + + var Menu = sdk.getComponent('context_menus.NotificationStateContextMenu'); + var elementRect = e.target.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page + var x = elementRect.right + window.pageXOffset + 3; + var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 53; + var self = this; + ContextualMenu.createMenu(Menu, { + menuWidth: 188, + menuHeight: 126, + chevronOffset: 45, + left: x, + top: y, + room: this.props.room, + onFinished: function() { + self.setState({ menu: false }); + } + }); + this.setState({ menu: true }); + } + }, + render: function() { var myUserId = MatrixClientPeg.get().credentials.userId; var me = this.props.room.currentState.members[myUserId]; @@ -84,60 +151,63 @@ module.exports = React.createClass({ 'mx_RoomTile_selected': this.props.selected, 'mx_RoomTile_unread': this.props.unread, 'mx_RoomTile_unreadNotify': notificationCount > 0, + 'mx_RoomTile_read': !(this.props.highlight || notificationCount > 0), 'mx_RoomTile_highlight': this.props.highlight, 'mx_RoomTile_invited': (me && me.membership == 'invite'), + 'mx_RoomTile_menu': this.state.menu, + }); + + var avatarClasses = classNames({ + 'mx_RoomTile_avatar': true, + 'mx_RoomTile_mute': this.state.areNotifsMuted, + }); + + var badgeClasses = classNames({ + 'mx_RoomTile_badge': true, + 'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menu, + 'mx_RoomTile_badgeMute': this.state.areNotifsMuted, }); // XXX: We should never display raw room IDs, but sometimes the // room name js sdk gives is undefined (cannot repro this -- k) var name = this.props.room.name || this.props.room.roomId; - name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon + var badge; var badgeContent; - var badgeClasses; - if (this.state.badgeHover) { + if (this.state.badgeHover || this.state.menu) { badgeContent = "\u00B7\u00B7\u00B7"; } else if (this.props.highlight || notificationCount > 0) { - badgeContent = notificationCount ? notificationCount : '!'; + var limitedCount = (notificationCount > 99) ? '99+' : notificationCount; + badgeContent = notificationCount ? limitedCount : '!'; } else { badgeContent = '\u200B'; } - if (this.props.highlight || notificationCount > 0) { - badgeClasses = "mx_RoomTile_badge"; + if (this.state.areNotifsMuted && !(this.state.badgeHover || this.state.menu)) { + badge =

; } else { - badgeClasses = "mx_RoomTile_badge mx_RoomTile_badge_no_unread"; + badge =
{ badgeContent }
; } - badge =
{ badgeContent }
; - - /* - if (this.props.highlight) { - badge =
!
; - } - else if (this.props.unread) { - badge =
1
; - } - var nameCell; - if (badge) { - nameCell =
{name}
{badge}
; - } - else { - nameCell =
{name}
; - } - */ - var label; + var tooltip; if (!this.props.collapsed) { - var className = 'mx_RoomTile_name' + (this.props.isInvite ? ' mx_RoomTile_invite' : ''); + var nameClasses = classNames({ + 'mx_RoomTile_name': true, + 'mx_RoomTile_invite': this.props.isInvite, + 'mx_RoomTile_mute': this.state.areNotifsMuted, + 'mx_RoomTile_badgeShown': this.props.highlight || notificationCount > 0 || this.state.badgeHover || this.state.menu || this.state.areNotifsMuted, + }); + let nameHTML = emojifyText(name); if (this.props.selected) { - name = ; - label =
{ name }
; + let nameSelected = ; + + label =
{ nameSelected }
; } else { - label =
; + label =
; } } else if (this.state.hover) { @@ -160,13 +230,16 @@ module.exports = React.createClass({ var connectDropTarget = this.props.connectDropTarget; return connectDragSource(connectDropTarget( -
-
- +
+
+ +
+
+ { label } + { badge }
- { label } - { badge } { incomingCallBox } + { tooltip }
)); } diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index 6858e62102..b660f196c8 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -111,7 +111,9 @@ export default class DevicesPanelEntry extends React.Component {
+ onSubmit={this._onDisplayNameChanged} + placeholder={device.device_id} + />
{lastSeen} diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index cd9d86cd64..7a603d138f 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -224,7 +224,7 @@ describe('TimelinePanel', function() { var scrollDefer; var panel = ReactDOM.render( - {scrollDefer.resolve()}} />, + {scrollDefer.resolve()}} />, parentDiv ); console.log("TimelinePanel rendered"); @@ -238,17 +238,29 @@ describe('TimelinePanel', function() { // the TimelinePanel fires a scroll event var awaitScroll = function() { scrollDefer = q.defer(); - return scrollDefer.promise; + return scrollDefer.promise.then(() => { + console.log("got scroll event; scrollTop now " + + scrollingDiv.scrollTop); + }); }; + function setScrollTop(scrollTop) { + const before = scrollingDiv.scrollTop; + scrollingDiv.scrollTop = scrollTop; + console.log("setScrollTop: before update: " + before + + "; assigned: " + scrollTop + + "; after update: " + scrollingDiv.scrollTop); + } + function backPaginate() { - scrollingDiv.scrollTop = 0; + console.log("back paginating..."); + setScrollTop(0); return awaitScroll().then(() => { if(scrollingDiv.scrollTop > 0) { // need to go further return backPaginate(); } - console.log("paginated to end."); + console.log("paginated to start."); // hopefully, we got to the start of the timeline expect(messagePanel.props.backPaginating).toBe(false); @@ -262,7 +274,6 @@ describe('TimelinePanel', function() { expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); // back-paginate until we hit the start - console.log("back paginating..."); return backPaginate(); }).then(() => { expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); @@ -271,8 +282,7 @@ describe('TimelinePanel', function() { // we should now be able to scroll down, and paginate in the other // direction. - console.log("scrollingDiv.scrollTop is " + scrollingDiv.scrollTop); - console.log("Going to set it to " + scrollingDiv.scrollHeight); + setScrollTop(scrollingDiv.scrollHeight); scrollingDiv.scrollTop = scrollingDiv.scrollHeight; return awaitScroll(); }).then(() => {