diff --git a/.eslintrc.json b/.eslintrc.json index c9996bc199cf..e117ca250fc4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,7 +19,8 @@ "browser": true, "node": true, "jquery": true, - "es6": true + "es6": true, + "jest": true }, "globals": { "jest": true, @@ -27,7 +28,8 @@ "it": true, "expect": true, "before": true, - "after": true + "after": true, + "beforeEach": true }, "settings": { "import/resolver": "webpack" @@ -42,7 +44,7 @@ "brace-style": [2, "1tbs", { "allowSingleLine": false }], "camelcase": [2, {"properties": "never"}], "capitalized-comments": 0, - "class-methods-use-this": 1, + "class-methods-use-this": 0, "comma-dangle": [2, "never"], "comma-spacing": [2, {"before": false, "after": true}], "comma-style": [2, "last"], @@ -226,7 +228,6 @@ "react/jsx-no-target-blank": 2, "react/jsx-no-undef": 2, "react/jsx-pascal-case": 2, - "react/jsx-space-before-closing": [2, "never"], "react/jsx-tag-spacing": [2, { "closingSlash": "never", "beforeSelfClosing": "never", "afterOpening": "never" }], "react/jsx-uses-react": 2, "react/jsx-uses-vars": 2, diff --git a/NOTICE.txt b/NOTICE.txt index 5f8ba3cc2f84..489a9e222bd2 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -754,39 +754,6 @@ THE SOFTWARE. --- -## match-at - -This product contains a modified portion of 'match-at', to correspond to the original string, where it doesn't slice the string by Ben Alpert. - -* HOMEPAGE: - * https://github.com/spicyj/match-at - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) 2015 Ben Alpert - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ----- - ## mattermost-redux This product contains a modified portion of `mattermost-redux`. @@ -806,39 +773,6 @@ Unless required by applicable law or agreed to in writing, software distributed --- -## object-assign - -This product contains a modified portion of 'object-assign', a polyfill that doesn't overwrite the native method by Sindre Sorhus. - -* HOMEPAGE: - * https://github.com/sindresorhus/object-assign - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - ## pdfjs-dist This product contains a modified portion of 'pdfjs-dist', is a Portable Document Format (PDF) library that is built with HTML5 built by mozilla. @@ -1513,39 +1447,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- -## velocity-animate - -This product contains a modified portion of 'velocity-animate', an animation engine with the same API as jQuery's $.animate() by Julian Shapiro. - -* HOMEPAGE: - * https://github.com/julianshapiro/velocity - -* LICENSE: - -The MIT License - -Copyright (c) 2014 Julian Shapiro - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - ## twemoji This product contains a modified portion of 'twemoji', a set of emoticons by Twitter, Inc. diff --git a/actions/admin_actions.jsx b/actions/admin_actions.jsx index 88e1b07484e7..8046348097b6 100644 --- a/actions/admin_actions.jsx +++ b/actions/admin_actions.jsx @@ -86,8 +86,8 @@ export async function recycleDatabaseConnection(success, error) { } } -export async function adminResetPassword(userId, password, success, error) { - const {data, error: err} = await UserActions.updateUserPassword(userId, '', password)(dispatch, getState); +export async function adminResetPassword(userId, currentPassword, password, success, error) { + const {data, error: err} = await UserActions.updateUserPassword(userId, currentPassword, password)(dispatch, getState); if (data && success) { success(data); } else if (err && error) { diff --git a/actions/diagnostics_actions.jsx b/actions/diagnostics_actions.jsx index 72cf387b4e0a..4178b51a58b8 100644 --- a/actions/diagnostics_actions.jsx +++ b/actions/diagnostics_actions.jsx @@ -22,3 +22,62 @@ export function trackEvent(category, event, props) { global.window.analytics.track('event', properties, options); } } + +/** + * Takes an array of string names of performance markers and invokes + * performance.clearMarkers on each. + * @param {array} names of markers to clear + * + */ +export function clearMarks(names) { + if (!global.mm_config.EnableDeveloper === 'true') { + return; + } + names.forEach((name) => performance.clearMarks(name)); +} + +export function mark(name) { + if (!global.mm_config.EnableDeveloper === 'true') { + return; + } + performance.mark(name); +} + +/** + * Takes the names of two markers and invokes performance.measure on + * them. The measured duration (ms) and the string name of the measure is + * are returned. + * + * @param {string} name1 the first marker + * @param {string} name2 the second marker + * + * @returns {[number, string]} Either the measured duration (ms) and the string name + * of the measure are returned or -1 and and empty string is returned if + * in dev. mode or one of the marker can't be found. + * + */ +export function measure(name1, name2) { + if (!global.mm_config.EnableDeveloper === 'true') { + return [-1, '']; + } + + // Check for existence of entry name to avoid DOMException + const performanceEntries = performance.getEntries(); + if (![name1, name2].every((name) => performanceEntries.find((item) => item.name === name))) { + return [-1, '']; + } + + const displayPrefix = '🐐 Mattermost: '; + const measurementName = `${displayPrefix}${name1} - ${name2}`; + performance.measure(measurementName, name1, name2); + const lastDuration = mostRecentDurationByEntryName(measurementName); + + // Clean up the measures we created + performance.clearMeasures(measurementName); + return [lastDuration, measurementName]; +} + +function mostRecentDurationByEntryName(entryName) { + const entriesWithName = performance.getEntriesByName(entryName); + return entriesWithName.map((item) => item.duration)[entriesWithName.length - 1]; +} \ No newline at end of file diff --git a/actions/integration_actions.jsx b/actions/integration_actions.jsx index a0fe57907342..8f007cd52f4e 100644 --- a/actions/integration_actions.jsx +++ b/actions/integration_actions.jsx @@ -246,16 +246,16 @@ export function getSuggestedCommands(command, suggestionId, component) { export function getYoutubeVideoInfo(googleKey, videoId, success, error) { request.get('https://www.googleapis.com/youtube/v3/videos'). - query({part: 'snippet', id: videoId, key: googleKey}). - end((err, res) => { - if (err) { - return error(err); - } + query({part: 'snippet', id: videoId, key: googleKey}). + end((err, res) => { + if (err) { + return error(err); + } - if (!res.body) { - console.error('Missing response body for getYoutubeVideoInfo'); // eslint-disable-line no-console - } + if (!res.body) { + console.error('Missing response body for getYoutubeVideoInfo'); // eslint-disable-line no-console + } - return success(res.body); - }); + return success(res.body); + }); } diff --git a/actions/storage.js b/actions/storage.js index a675e2226a7a..18d2aef6127e 100644 --- a/actions/storage.js +++ b/actions/storage.js @@ -84,11 +84,13 @@ export function storageRehydrate(incoming) { return async (dispatch, persistor) => { persistor.pause(); Object.keys(incoming).forEach((key) => { - let storage = {}; + const storage = {}; try { storage[key] = JSON.parse(incoming[key]); } catch (err) { - if (process.env.NODE_ENV !== 'production') console.warn(`Error rehydrating data for key "storage"`, err) + if (process.env.NODE_ENV !== 'production') { // eslint-disable-line no-process-env + console.warn('Error rehydrating data for key "storage"', err); // eslint-disable-line no-console + } } dispatch({ type: StorageTypes.STORAGE_REHYDRATE, diff --git a/actions/views/create_comment.jsx b/actions/views/create_comment.jsx index 2ca2e5b482d6..3947d107cc22 100644 --- a/actions/views/create_comment.jsx +++ b/actions/views/create_comment.jsx @@ -9,11 +9,11 @@ import {makeGetMessageInHistoryItem, makeGetCommentCountForPost, getPost} from ' import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis'; import { - addReaction, - removeReaction, - addMessageIntoHistory, - moveHistoryIndexBack, - moveHistoryIndexForward + addReaction, + removeReaction, + addMessageIntoHistory, + moveHistoryIndexBack, + moveHistoryIndexForward } from 'mattermost-redux/actions/posts'; import {Posts} from 'mattermost-redux/constants'; @@ -94,6 +94,7 @@ export function submitReaction(postId, action, emojiName) { return (dispatch) => { if (action === '+') { dispatch(addReaction(postId, emojiName)); + PostActions.emitEmojiPosted(emojiName); } else if (action === '-') { dispatch(removeReaction(postId, emojiName)); } diff --git a/actions/views/modals.js b/actions/views/modals.js index a0d1135398c6..570f38ddc5c2 100644 --- a/actions/views/modals.js +++ b/actions/views/modals.js @@ -1,8 +1,10 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + import {ActionTypes} from 'utils/constants'; -import * as Utils from 'utils/utils'; export function openModal(modalData) { - return (dispatch, getState) => { + return (dispatch) => { const action = { type: ActionTypes.MODAL_OPEN, modalId: modalData.modalId, @@ -15,12 +17,12 @@ export function openModal(modalData) { } export function closeModal(modalId) { - return (dispatch, getState) => { + return (dispatch) => { const action = { type: ActionTypes.MODAL_CLOSE, - modalId: modalId + modalId }; dispatch(action); }; -} \ No newline at end of file +} diff --git a/actions/views/rhs.js b/actions/views/rhs.js index fda6deb35452..f1e472154dec 100644 --- a/actions/views/rhs.js +++ b/actions/views/rhs.js @@ -132,7 +132,7 @@ export function getFlaggedPosts() { await PostActions.getProfilesAndStatusesForPosts(result.posts, dispatch, getState); const searchActions = getSearchActions(result, teamId); - + dispatch(batchActions(searchActions)); }; } @@ -154,7 +154,7 @@ export function showFlaggedPosts() { let result; try { result = await Client4.getFlaggedPosts(userId, '', teamId); - } catch(error) { + } catch (error) { dispatch({type: ActionTypes.SEARCH_FLAGGED_POSTS_FAILURE, error}); } @@ -201,7 +201,7 @@ export function showPinnedPosts(channelId) { let result; try { result = await Client4.getPinnedPosts(channelId || currentChannelId); - } catch(error) { + } catch (error) { dispatch({type: ActionTypes.SEARCH_PINNED_POSTS_FAILURE, error}); } diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index 493d712a4a74..82cff1f3378f 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -11,6 +11,7 @@ import {getChannelAndMyMember, getChannelStats, viewChannel} from 'mattermost-re import {setServerVersion} from 'mattermost-redux/actions/general'; import {getPosts, getProfilesAndStatusesForPosts} from 'mattermost-redux/actions/posts'; import * as TeamActions from 'mattermost-redux/actions/teams'; +import {getMe} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; @@ -412,13 +413,11 @@ function handleUserUpdatedEvent(msg) { const user = msg.data.user; if (currentUser.id === user.id) { - dispatch({ - type: UserTypes.RECEIVED_ME, - data: { - ...currentUser, - last_picture_update: user.last_picture_update - } - }); + if (user.update_at > currentUser.update_at) { + // Need to request me to make sure we don't override with sanitized fields from the + // websocket event + getMe()(dispatch, getState); + } } else { UserStore.saveProfile(user); } diff --git a/components/about_build_modal/about_build_modal.jsx b/components/about_build_modal/about_build_modal.jsx index 14437d8cb6df..690886ee049e 100644 --- a/components/about_build_modal/about_build_modal.jsx +++ b/components/about_build_modal/about_build_modal.jsx @@ -6,7 +6,7 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedHTMLMessage, FormattedMessage} from 'react-intl'; -import Constants from 'utils/constants.jsx'; +import MattermostLogo from 'components/svg/mattermost_logo'; export default class AboutBuildModal extends React.PureComponent { static defaultProps = { @@ -48,7 +48,6 @@ export default class AboutBuildModal extends React.PureComponent { render() { const config = this.props.config; const license = this.props.license; - const mattermostLogo = Constants.MATTERMOST_ICON_SVG; let title = (
- +

{'Mattermost'} {title}

diff --git a/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.jsx b/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.jsx index 8105a1595493..c204f1311188 100644 --- a/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.jsx +++ b/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.jsx @@ -12,12 +12,12 @@ import {Link} from 'react-router'; import * as GlobalActions from 'actions/global_actions.jsx'; import TeamStore from 'stores/team_store.jsx'; -import Constants from 'utils/constants.jsx'; import {sortTeamsByDisplayName} from 'utils/team_utils.jsx'; import * as Utils from 'utils/utils.jsx'; import AboutBuildModal from 'components/about_build_modal'; import BlockableLink from 'components/admin_console/blockable_link'; +import MenuIcon from 'components/svg/menu_icon'; export default class AdminNavbarDropdown extends React.Component { static propTypes = { @@ -159,10 +159,7 @@ export default class AdminNavbarDropdown extends React.Component { role='button' aria-expanded='false' > - +
    this.setState({loading: false}) - ); + ); } componentWillUnmount() { diff --git a/components/admin_console/reset_password_modal.jsx b/components/admin_console/reset_password_modal.jsx index 5713187239f1..13079ef17fc0 100644 --- a/components/admin_console/reset_password_modal.jsx +++ b/components/admin_console/reset_password_modal.jsx @@ -7,6 +7,7 @@ import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import {adminResetPassword} from 'actions/admin_actions.jsx'; +import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -29,35 +30,64 @@ export default class ResetPasswordModal extends React.Component { this.doCancel = this.doCancel.bind(this); this.state = { - serverError: null + serverErrorNewPass: null, + serverErrorCurrentPass: null }; } + componentWillUnmount() { + this.setState({ + serverErrorNewPass: null, + serverErrorCurrentPass: null + }); + } + doSubmit(e) { e.preventDefault(); + let currentPassword = ''; + if (this.refs.currentPassword) { + currentPassword = this.refs.currentPassword.value; + if (currentPassword === '') { + let errorMsg = ''; + errorMsg = ( + + ); + this.setState({serverErrorCurrentPass: errorMsg}); + return; + } + } + const password = this.refs.password.value; const passwordErr = Utils.isValidPassword(password, Utils.getPasswordConfig()); if (passwordErr) { - this.setState({serverError: passwordErr}); + this.setState({serverErrorNewPass: passwordErr}); return; } - this.setState({serverError: null}); + + this.setState({serverErrorNewPass: null}); adminResetPassword( this.props.user.id, + currentPassword, password, () => { this.props.onModalSubmit(this.props.user); }, (err) => { - this.setState({serverError: err.message}); + this.setState({serverErrorCurrentPass: err.message}); } ); } doCancel() { - this.setState({serverError: null}); + this.setState({ + serverErrorNewPass: null, + serverErrorCurrentPass: null + }); this.props.onModalDismissed(); } @@ -68,11 +98,11 @@ export default class ResetPasswordModal extends React.Component { } let urlClass = 'input-group input-group--limit'; - let serverError = null; + let serverErrorNewPass = null; - if (this.state.serverError) { + if (this.state.serverErrorNewPass) { urlClass += ' has-error'; - serverError =

    {this.state.serverError}

    ; + serverErrorNewPass =

    {this.state.serverErrorNewPass}

    ; } let title; @@ -92,6 +122,43 @@ export default class ResetPasswordModal extends React.Component { ); } + const currentUserId = UserStore.getCurrentId(); + let currentPassword = null; + let serverErrorCurrentPass = null; + let newPasswordFocus = true; + if (currentUserId === user.id) { + newPasswordFocus = false; + let urlClassCurrentPass = 'input-group input-group--limit'; + if (this.state.serverErrorCurrentPass) { + urlClassCurrentPass += ' has-error'; + serverErrorCurrentPass =

    {this.state.serverErrorCurrentPass}

    ; + } + currentPassword = ( +
    +
    + + + + +
    +
    + ); + } + return (
    + {currentPassword}
    - {serverError} + {serverErrorNewPass} + {serverErrorCurrentPass}
    diff --git a/components/backstage/backstage_controller.jsx b/components/backstage/backstage_controller.jsx index 67d25aa178c4..f3de4bf85801 100644 --- a/components/backstage/backstage_controller.jsx +++ b/components/backstage/backstage_controller.jsx @@ -47,11 +47,11 @@ export default class BackstageController extends React.Component { onTeamChange() { const team = TeamStore.getCurrent(); - this.state = { + this.setState({ team, isAdmin: UserStore.isSystemAdminForCurrentUser(this.props.user) || TeamStore.isTeamAdminForCurrentTeam(team) - }; + }); } render() { diff --git a/components/bootstrap_span.jsx b/components/bootstrap_span.jsx index f20a2b084ea4..518bf9ae4be2 100644 --- a/components/bootstrap_span.jsx +++ b/components/bootstrap_span.jsx @@ -5,7 +5,6 @@ import PropTypes from 'prop-types'; import React from 'react'; class BootstrapSpan extends React.PureComponent { - static propTypes = { children: PropTypes.element } diff --git a/components/channel_header/channel_header.jsx b/components/channel_header/channel_header.jsx index 5b15bb550dfb..c29d012c9955 100644 --- a/components/channel_header/channel_header.jsx +++ b/components/channel_header/channel_header.jsx @@ -12,8 +12,11 @@ import * as GlobalActions from 'actions/global_actions.jsx'; import * as WebrtcActions from 'actions/webrtc_actions.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import WebrtcStore from 'stores/webrtc_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; +import MessageWrapper from 'components/message_wrapper.jsx'; import {ActionTypes, Constants, RHSStates, UserStatuses, ModalIdentifiers} from 'utils/constants.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import {getSiteURL} from 'utils/url.jsx'; @@ -21,16 +24,18 @@ import * as Utils from 'utils/utils.jsx'; import ChannelInfoModal from 'components/channel_info_modal'; import ChannelInviteModal from 'components/channel_invite_modal'; -import ChannelMembersModal from 'components/channel_members_modal.jsx'; +import ChannelMembersModal from 'components/channel_members_modal'; import ChannelNotificationsModal from 'components/channel_notifications_modal.jsx'; import DeleteChannelModal from 'components/delete_channel_modal'; import EditChannelHeaderModal from 'components/edit_channel_header_modal'; import EditChannelPurposeModal from 'components/edit_channel_purpose_modal'; -import MessageWrapper from 'components/message_wrapper.jsx'; import PopoverListMembers from 'components/popover_list_members'; import RenameChannelModal from 'components/rename_channel_modal'; import NavbarSearchBox from 'components/search_bar'; import StatusIcon from 'components/status_icon.jsx'; +import FlagIcon from 'components/svg/flag_icon'; +import MentionsIcon from 'components/svg/mentions_icon'; +import PinIcon from 'components/svg/pin_icon'; import ToggleModalButtonRedux from 'components/toggle_modal_button_redux'; import Pluggable from 'plugins/pluggable'; @@ -207,10 +212,6 @@ export default class ChannelHeader extends React.Component { ); } - const flagIcon = Constants.FLAG_ICON_SVG; - const pinIcon = Constants.PIN_ICON_SVG; - const mentionsIcon = Constants.MENTIONS_ICON_SVG; - const channel = this.props.channel; const recentMentionsTooltip = ( @@ -242,7 +243,7 @@ export default class ChannelHeader extends React.Component { /> ); - + const textFormattingOptions = {singleline: true, mentionHighlight: false, siteURL: getSiteURL(), channelNamesMap: ChannelStore.getChannelNamesMap(), team: TeamStore.getCurrent(), atMentions: true}; const popoverContent = ( ); @@ -730,7 +732,7 @@ export default class ChannelHeader extends React.Component { {dmHeaderTextStatus}
); @@ -937,9 +939,8 @@ export default class ChannelHeader extends React.Component { className={'style--none ' + pinnedIconClass} onClick={this.getPinnedPosts} > -
diff --git a/components/channel_info_modal/channel_info_modal.jsx b/components/channel_info_modal/channel_info_modal.jsx index 923acee5eebd..e8f24788f2e7 100644 --- a/components/channel_info_modal/channel_info_modal.jsx +++ b/components/channel_info_modal/channel_info_modal.jsx @@ -6,6 +6,9 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import GlobeIcon from 'components/svg/globe_icon'; +import LockIcon from 'components/svg/lock_icon'; + import Constants from 'utils/constants.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import {getSiteURL} from 'utils/url.jsx'; @@ -45,8 +48,6 @@ export default class ChannelInfoModal extends React.PureComponent { render() { let channel = this.props.channel; let channelIcon; - const globeIcon = Constants.GLOBE_ICON_SVG; - const lockIcon = Constants.LOCK_ICON_SVG; if (!channel) { const notFound = Utils.localizeMessage('channel_info.notFound', 'No Channel Found'); @@ -62,17 +63,11 @@ export default class ChannelInfoModal extends React.PureComponent { if (channel.type === 'O') { channelIcon = ( - + ); } else if (channel.type === 'P') { channelIcon = ( - + ); } diff --git a/components/channel_members_modal.jsx b/components/channel_members_modal.jsx deleted file mode 100644 index 7f26e9f11d83..000000000000 --- a/components/channel_members_modal.jsx +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PropTypes from 'prop-types'; -import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; - -import ChannelStore from 'stores/channel_store.jsx'; -import TeamStore from 'stores/team_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import {canManageMembers} from 'utils/channel_utils.jsx'; -import {Constants} from 'utils/constants.jsx'; - -import MemberListChannel from 'components/member_list_channel'; - -export default class ChannelMembersModal extends React.Component { - constructor(props) { - super(props); - - this.onHide = this.onHide.bind(this); - - this.state = { - channel: this.props.channel, - show: true - }; - } - - onHide() { - this.setState({show: false}); - } - - render() { - const isSystemAdmin = UserStore.isSystemAdminForCurrentUser(); - const isTeamAdmin = TeamStore.isTeamAdminForCurrentTeam(); - const isChannelAdmin = ChannelStore.isChannelAdminForCurrentChannel(); - - let addMembersButton = null; - if (canManageMembers(this.state.channel, isChannelAdmin, isTeamAdmin, isSystemAdmin) && this.state.channel.name !== Constants.DEFAULT_CHANNEL) { - addMembersButton = ( - { - this.props.showInviteModal(); - this.onHide(); - }} - > - - - ); - } - - return ( -
- - - - {this.props.channel.display_name} - - - {addMembersButton} - - - - - -
- ); - } -} - -ChannelMembersModal.propTypes = { - onModalDismissed: PropTypes.func.isRequired, - showInviteModal: PropTypes.func.isRequired, - channel: PropTypes.object.isRequired -}; diff --git a/components/channel_members_modal/channel_members_modal.jsx b/components/channel_members_modal/channel_members_modal.jsx new file mode 100644 index 000000000000..42ba9d705458 --- /dev/null +++ b/components/channel_members_modal/channel_members_modal.jsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; + +import MemberListChannel from 'components/member_list_channel'; + +export default class ChannelMembersModal extends React.PureComponent { + static propTypes = { + canManageChannelMembers: PropTypes.bool.isRequired, + channel: PropTypes.shape({ + display_name: PropTypes.string.isRequired + }).isRequired, + onModalDismissed: PropTypes.func.isRequired, + showInviteModal: PropTypes.bool.isRequired + } + + state = { + show: true + }; + + onHide = () => { + this.setState({ + show: false + }); + } + + onClickManageChannelsButton = () => { + this.props.showInviteModal(); + this.onHide(); + } + + render() { + return ( +
+ + + + {this.props.channel.display_name} + + + {this.props.canManageChannelMembers && + + + + } + + + + + +
+ ); + } +} diff --git a/components/channel_members_modal/channel_members_modal.test.jsx b/components/channel_members_modal/channel_members_modal.test.jsx new file mode 100644 index 000000000000..8974c0ca2fa5 --- /dev/null +++ b/components/channel_members_modal/channel_members_modal.test.jsx @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import {Provider} from 'react-redux'; +import {applyMiddleware, combineReducers, createStore} from 'redux'; +import thunk from 'redux-thunk'; +import {Modal} from 'react-bootstrap'; + +import {mountWithIntl} from 'tests/helpers/intl-test-helper.jsx'; + +import ChannelMembersModal from 'components/channel_members_modal/channel_members_modal'; + +describe('components/ChannelMembersModal', () => { + let createComponent; + let props; + let reduxState; + beforeEach(() => { + props = { + channel: { + display_name: 'testchannel', + header: '', + name: 'testchannel', + purpose: '' + } + }; + reduxState = {}; + function reducer() { + return reduxState; + } + createComponent = () => { + function Wrapped() { + return ( + + + + ); + } + return Wrapped; + }; + }); + + test('renders the channel display name', () => { + const Component = createComponent(); + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('.name').text()).toBe(props.channel.display_name); + }); + + test('should call the onHide callback when the modal is hidden', () => { + const Component = createComponent(); + props.onModalDismissed = jest.fn(); + const wrapper = mountWithIntl( + + ); + expect(props.onModalDismissed).not.toHaveBeenCalled(); + wrapper.find(Modal).first().props().onExited(); + expect(props.onModalDismissed).toHaveBeenCalled(); + }); + + test('should show the invite modal link if the user can manage channel members', () => { + const Component = createComponent(); + props.canManageChannelMembers = true; + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('#showInviteModal').length).toBe(1); + }); + + test('should not show the invite modal link if the user can not manage channel members', () => { + const Component = createComponent(); + props.canManageChannelMembers = false; + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('#showInviteModal').length).toBe(0); + }); + + test('should call showInviteModal when the invite modal link is clicked', () => { + const Component = createComponent(); + props.canManageChannelMembers = true; + props.showInviteModal = jest.fn(); + const wrapper = mountWithIntl( + + ); + expect(props.showInviteModal).not.toHaveBeenCalled(); + wrapper.find('#showInviteModal').first().simulate('click'); + expect(props.showInviteModal).toHaveBeenCalled(); + }); + + test('closes the Bootstrap modal when the modal close button is clicked', () => { + const Component = createComponent(); + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('.modal').first().hasClass('in')).toBe(true); + wrapper.find('button').first().simulate('click'); + expect(wrapper.find('.modal').first().hasClass('in')).toBe(false); + }); +}); diff --git a/components/channel_members_modal/index.js b/components/channel_members_modal/index.js new file mode 100644 index 000000000000..0ebf09b7e8c1 --- /dev/null +++ b/components/channel_members_modal/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {canManageChannelMembers} from 'mattermost-redux/selectors/entities/channels'; + +import ChannelMembersModal from './channel_members_modal'; + +function mapStateToProps(state) { + return { + canManageChannelMembers: canManageChannelMembers(state) + }; +} + +export default connect(mapStateToProps)(ChannelMembersModal); diff --git a/components/channel_view/channel_view.jsx b/components/channel_view/channel_view.jsx index 682d192cba05..89b72213accb 100644 --- a/components/channel_view/channel_view.jsx +++ b/components/channel_view/channel_view.jsx @@ -15,6 +15,7 @@ import CreatePost from 'components/create_post'; import FileUploadOverlay from 'components/file_upload_overlay.jsx'; import PostView from 'components/post_view'; import TutorialView from 'components/tutorial/tutorial_view.jsx'; +import {clearMarks, mark, measure, trackEvent} from 'actions/diagnostics_actions.jsx'; export default class ChannelView extends React.PureComponent { static propTypes = { @@ -70,6 +71,28 @@ export default class ChannelView extends React.PureComponent { return this.refs.channelView; } + componentDidUpdate(prevProps) { + if (prevProps.channelId !== this.props.channelId) { + mark('ChannelView#componentDidUpdate'); + + const [dur1] = measure('SidebarChannelLink#click', 'ChannelView#componentDidUpdate'); + const [dur2] = measure('TeamLink#click', 'ChannelView#componentDidUpdate'); + + clearMarks([ + 'SidebarChannelLink#click', + 'ChannelView#componentDidUpdate', + 'TeamLink#click' + ]); + + if (dur1 !== -1) { + trackEvent('performance', 'channel_switch', {duration: Math.round(dur1)}); + } + if (dur2 !== -1) { + trackEvent('performance', 'team_switch', {duration: Math.round(dur2)}); + } + } + } + render() { if (this.props.showTutorial) { return ( diff --git a/components/channel_view/index.js b/components/channel_view/index.js index 08eaff409bfd..2977efd65dee 100644 --- a/components/channel_view/index.js +++ b/components/channel_view/index.js @@ -7,7 +7,7 @@ import {createSelector} from 'reselect'; import {get} from 'mattermost-redux/selectors/entities/preferences'; import {getDirectTeammate} from 'utils/utils.jsx'; -import {Constants, TutorialSteps, Preferences} from 'utils/constants.jsx'; +import {TutorialSteps, Preferences} from 'utils/constants.jsx'; import ChannelView from './channel_view.jsx'; @@ -21,13 +21,13 @@ const getDeactivatedChannel = createSelector( } ); -function mapStateToProps(state, ownProps) { +function mapStateToProps(state) { const channelId = state.entities.channels.currentChannelId; return { channelId, deactivatedChannel: getDeactivatedChannel(state, channelId), - showTutorial: Number(get(state, Preferences.TUTORIAL_STEP, state.entities.users.currentUserId, 999)) <= TutorialSteps.INTRO_SCREENS + showTutorial: Number(get(state, Preferences.TUTORIAL_STEP, state.entities.users.currentUserId, 999)) <= TutorialSteps.INTRO_SCREENS && global.window.mm_config.EnableTutorial === 'true' }; } diff --git a/components/common/comment_icon.jsx b/components/common/comment_icon.jsx index ade4aeb8399a..b20050901d29 100644 --- a/components/common/comment_icon.jsx +++ b/components/common/comment_icon.jsx @@ -4,57 +4,57 @@ import PropTypes from 'prop-types'; import React from 'react'; -import Constants from 'utils/constants.jsx'; +import ReplyIcon from 'components/svg/reply_icon'; + import * as Utils from 'utils/utils.jsx'; -export default function CommentIcon(props) { - let commentCountSpan = ''; - let iconStyle = 'comment-icon__container'; - if (props.commentCount > 0) { - iconStyle += ' icon--show'; - commentCountSpan = ( - - {props.commentCount} - +export default class CommentIcon extends React.PureComponent { + static propTypes = { + idPrefix: PropTypes.string.isRequired, + idCount: PropTypes.number, + handleCommentClick: PropTypes.func.isRequired, + searchStyle: PropTypes.string, + commentCount: PropTypes.number, + id: PropTypes.string + }; + + static defaultProps = { + idCount: -1, + searchStyle: '', + commentCount: 0, + id: '' + }; + + render() { + let commentCountSpan = ''; + let iconStyle = 'comment-icon__container'; + if (this.props.commentCount > 0) { + iconStyle += ' icon--show'; + commentCountSpan = ( + + {this.props.commentCount} + + ); + } else if (this.props.searchStyle !== '') { + iconStyle = iconStyle + ' ' + this.props.searchStyle; + } + + let selectorId = this.props.idPrefix; + if (this.props.idCount > -1) { + selectorId += this.props.idCount; + } + + const id = Utils.createSafeId(this.props.idPrefix + '_' + this.props.id); + + return ( + ); - } else if (props.searchStyle !== '') { - iconStyle = iconStyle + ' ' + props.searchStyle; - } - - let selectorId = props.idPrefix; - if (props.idCount > -1) { - selectorId += props.idCount; } - - const id = Utils.createSafeId(props.idPrefix + '_' + props.id); - - return ( - - ); } - -CommentIcon.propTypes = { - idPrefix: PropTypes.string.isRequired, - idCount: PropTypes.number, - handleCommentClick: PropTypes.func.isRequired, - searchStyle: PropTypes.string, - commentCount: PropTypes.number, - id: PropTypes.string -}; - -CommentIcon.defaultProps = { - idCount: -1, - searchStyle: '', - commentCount: 0, - id: '' -}; diff --git a/components/create_comment/create_comment.jsx b/components/create_comment/create_comment.jsx index 7414848870d1..f954531b9225 100644 --- a/components/create_comment/create_comment.jsx +++ b/components/create_comment/create_comment.jsx @@ -9,17 +9,18 @@ import {FormattedMessage} from 'react-intl'; import * as GlobalActions from 'actions/global_actions.jsx'; -import Constants from 'utils/constants.jsx'; -import * as UserAgent from 'utils/user_agent.jsx'; -import * as Utils from 'utils/utils.jsx'; - import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; import FilePreview from 'components/file_preview.jsx'; import FileUpload from 'components/file_upload.jsx'; import MsgTyping from 'components/msg_typing.jsx'; import PostDeletedModal from 'components/post_deleted_modal.jsx'; +import EmojiIcon from 'components/svg/emoji_icon'; import Textbox from 'components/textbox.jsx'; +import Constants from 'utils/constants.jsx'; +import * as UserAgent from 'utils/user_agent.jsx'; +import * as Utils from 'utils/utils.jsx'; + const KeyCodes = Constants.KeyCodes; export default class CreateComment extends React.PureComponent { @@ -54,7 +55,7 @@ export default class CreateComment extends React.PureComponent { */ ctrlSend: PropTypes.bool, - /** + /** * The id of the latest post in this channel */ latestPostId: PropTypes.string, @@ -505,9 +506,8 @@ export default class CreateComment extends React.PureComponent { rightOffset={15} topOffset={55} /> - diff --git a/components/create_comment/index.js b/components/create_comment/index.js index d505703ab5fe..5e9a7f588bb5 100644 --- a/components/create_comment/index.js +++ b/components/create_comment/index.js @@ -21,7 +21,7 @@ import CreateComment from './create_comment.jsx'; function mapStateToProps(state, ownProps) { const err = state.requests.posts.createPost.error || {}; - + const getCommentDraft = makeGetCommentDraft(ownProps.rootId); const draft = getCommentDraft(state); diff --git a/components/create_post/create_post.jsx b/components/create_post/create_post.jsx index 003d5e2d0960..9b0f521a24ea 100644 --- a/components/create_post/create_post.jsx +++ b/components/create_post/create_post.jsx @@ -9,6 +9,7 @@ import {Posts} from 'mattermost-redux/constants'; import * as ChannelActions from 'actions/channel_actions.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import {emitEmojiPosted} from 'actions/post_actions.jsx'; import EmojiStore from 'stores/emoji_store.jsx'; import Constants, {StoragePrefixes} from 'utils/constants.jsx'; import * as FileUtils from 'utils/file_utils'; @@ -16,13 +17,13 @@ import * as PostUtils from 'utils/post_utils.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; import * as Utils from 'utils/utils.jsx'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; - import ConfirmModal from 'components/confirm_modal.jsx'; +import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; import FilePreview from 'components/file_preview.jsx'; import FileUpload from 'components/file_upload.jsx'; import MsgTyping from 'components/msg_typing.jsx'; import PostDeletedModal from 'components/post_deleted_modal.jsx'; +import EmojiIcon from 'components/svg/emoji_icon'; import Textbox from 'components/textbox.jsx'; import TutorialTip from 'components/tutorial/tutorial_tip.jsx'; @@ -377,6 +378,7 @@ export default class CreatePost extends React.Component { if (postId && action === '+') { this.props.actions.addReaction(postId, emojiName); + emitEmojiPosted(emojiName); } else if (postId && action === '-') { this.props.actions.removeReaction(postId, emojiName); } @@ -733,7 +735,7 @@ export default class CreatePost extends React.Component { } let tutorialTip = null; - if (parseInt(this.props.showTutorialTip, 10) === TutorialSteps.POST_POPOVER) { + if (parseInt(this.props.showTutorialTip, 10) === TutorialSteps.POST_POPOVER && global.window.mm_config.EnableTutorial === 'true') { tutorialTip = this.createTutorialTip(); } @@ -779,10 +781,9 @@ export default class CreatePost extends React.Component { rightOffset={15} topOffset={-7} /> - diff --git a/components/deferComponentRender.js b/components/deferComponentRender.jsx similarity index 94% rename from components/deferComponentRender.js rename to components/deferComponentRender.jsx index d06ac8eef9be..9406c3194bb2 100644 --- a/components/deferComponentRender.js +++ b/components/deferComponentRender.jsx @@ -25,12 +25,12 @@ export default function deferComponentRender(WrappedComponent, PreRenderComponen componentDidMount() { window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => this.setState({ shouldRender: true })); + window.requestAnimationFrame(() => this.setState({shouldRender: true})); }); } render() { - return this.state.shouldRender ? : PreRenderComponent; + return this.state.shouldRender ? : PreRenderComponent; } } diff --git a/components/edit_post_modal/edit_post_modal.jsx b/components/edit_post_modal/edit_post_modal.jsx index b87d5f237a8b..1a29bf1e6d24 100644 --- a/components/edit_post_modal/edit_post_modal.jsx +++ b/components/edit_post_modal/edit_post_modal.jsx @@ -17,8 +17,8 @@ import * as UserAgent from 'utils/user_agent.jsx'; import * as Utils from 'utils/utils.jsx'; import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; - -import Textbox from '../textbox.jsx'; +import EmojiIcon from 'components/svg/emoji_icon'; +import Textbox from 'components/textbox.jsx'; const KeyCodes = Constants.KeyCodes; @@ -234,10 +234,9 @@ export default class EditPostModal extends React.PureComponent { rightOffset={50} topOffset={-20} /> - ); diff --git a/components/edit_post_modal/index.js b/components/edit_post_modal/index.js index 569938798fa9..687d80aec1e5 100644 --- a/components/edit_post_modal/index.js +++ b/components/edit_post_modal/index.js @@ -6,8 +6,8 @@ import {bindActionCreators} from 'redux'; import {addMessageIntoHistory} from 'mattermost-redux/actions/posts'; import {Preferences} from 'mattermost-redux/constants'; -import {getBool} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; import {setEditingPost} from 'actions/post_actions'; import {editPost} from 'actions/views/edit_post_modal'; @@ -16,9 +16,8 @@ import {getEditingPost} from 'selectors/posts'; import EditPostModal from './edit_post_modal.jsx'; -function mapStateToProps(state, ownProps) { +function mapStateToProps(state) { return { - ...ownProps, ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'), config: getConfig(state), editingPost: getEditingPost(state) diff --git a/components/emoji/components/delete_emoji_modal.jsx b/components/emoji/components/delete_emoji_modal.jsx index ea1a169234e1..9aff5e46e143 100644 --- a/components/emoji/components/delete_emoji_modal.jsx +++ b/components/emoji/components/delete_emoji_modal.jsx @@ -5,9 +5,14 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage} from 'react-intl'; -import DeleteModalTrigger from '../../delete_modal_trigger.jsx'; +import DeleteModalTrigger from 'components/delete_modal_trigger.jsx'; export default class DeleteEmoji extends DeleteModalTrigger { + + static propTypes = { + onDelete: PropTypes.func.isRequired + } + get triggerTitle() { return ( { + this.emojiItem = emojiItem; + }; + componentWillReceiveProps(nextProps) { + if (!this.props.isSelected && nextProps.isSelected) { + const topOfTheEmojiContainer = this.emojiItem.offsetTop; + const heightOfTheEmojiContainer = this.emojiItem.offsetHeight; + if (topOfTheEmojiContainer < this.props.containerTop) { + this.emojiItem.scrollIntoView(); + nextProps.containerRef.scrollTop -= SCROLLING_ADDT_VISUAL_SPACING; + } else if (topOfTheEmojiContainer > this.props.containerBottom - heightOfTheEmojiContainer) { + this.emojiItem.scrollIntoView(false); + nextProps.containerRef.scrollTop += SCROLLING_ADDT_VISUAL_SPACING; + } + } } handleMouseOver() { - this.props.onItemOver(this.props.emoji); - } - - handleMouseOut() { - this.props.onItemOut(); + this.props.onItemOver(this.props.categoryIndex, this.props.emojiIndex); } handleClick() { @@ -41,39 +53,41 @@ export default class EmojiPickerItem extends React.PureComponent { } render() { - let item = null; const {emoji} = this.props; + let item = null; + let itemClassName = 'emoji-picker__item'; + itemClassName += this.props.isSelected ? ' selected' : ''; + let spriteClassName = 'emojisprite'; + spriteClassName += ' emoji-category-' + emoji.category + '-' + emoji.batch; + spriteClassName += ' emoji-' + emoji.filename; if (emoji.category && emoji.batch) { - let className = 'emojisprite'; - - className += ' emoji-category-' + emoji.category + '-' + emoji.batch; - className += ' emoji-' + emoji.filename; - item = ( -
+
); } else { item = ( - - +
); } diff --git a/components/emoji_picker/emoji_list.jsx b/components/emoji_picker/emoji_list.jsx deleted file mode 100644 index b75404871de7..000000000000 --- a/components/emoji_picker/emoji_list.jsx +++ /dev/null @@ -1,275 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; - -import EmojiListManager from './emoji_list_manager'; - -const SCROLL_STOP_DELAY = 50; -const STYLE_WRAPPER = { - willChange: 'transform', - WebkitOverflowScrolling: 'touch', - width: '100%' -}; -const STYLE_INNER = { - position: 'relative', - overflow: 'hidden', - width: '100%', - minHeight: '100%' -}; -const STYLE_ITEM = {position: 'absolute', left: 0, width: '100%'}; - -export default class EmojiList extends PureComponent { - static defaultProps = { - loadedItems: [] - }; - - static propTypes = { - - /** - * Height of the list which is used to determine the number of rendered items - */ - height: PropTypes.number.isRequired, - - /** - * An array on number representing loaded items. Each item represents emoji rows - */ - loadedItems: PropTypes.array.isRequired, - - /** - * The number of items to render - */ - itemCount: PropTypes.number.isRequired, - - /** - * Height of item - */ - itemSize: PropTypes.number.isRequired, - - /** - * Used to control scroll offset from the top - */ - scrollOffset: PropTypes.number, - - /** - * Function to render an item given its index and style - */ - renderItem: PropTypes.func.isRequired, - - /** - * Function to call whenever the scroll offset changes - */ - onScroll: PropTypes.func.isRequired, - - /** - * Function to call whenever items are loaded - */ - onLoadedItems: PropTypes.func - }; - - constructor(props) { - super(props); - - this.emojiListManager = new EmojiListManager({ - itemCount: props.itemCount, - itemSizeGetter: ({index}) => this.getSize(index), - itemSize: props.itemSize - }); - - const items = this.filterItems(props.loadedItems, props.itemCount); - this.state = { - offset: props.scrollOffset || 0, - bottom: 0, - stop: 0, - items - }; - - this.styleCache = {}; - this.isScrolling = {}; - } - - componentDidMount() { - const {scrollOffset} = this.props; - - if (scrollOffset != null) { - this.scrollTo(scrollOffset); - } - } - - componentWillReceiveProps(nextProps) { - const {itemCount, itemSize, scrollOffset, loadedItems} = this.props; - - let recompute = false; - const itemPropsHaveChanged = nextProps.itemCount !== itemCount || nextProps.itemSize !== itemSize; - if (itemPropsHaveChanged) { - recompute = true; - this.recomputeSizes(); - } - - let offset = 0; - let {items} = this.state; - if (nextProps.itemCount !== itemCount) { - items = []; - if (nextProps.itemCount > loadedItems.length) { - items = loadedItems; - } - - this.emojiListManager.updateConfig({ - itemCount: nextProps.itemCount, - itemSize: nextProps.itemSize - }); - - this.scrollTo(offset); - } else if (nextProps.scrollOffset !== scrollOffset) { - offset = nextProps.scrollOffset; - this.setState({offset}); - } - - this.setStopAndBottom(items, offset, recompute, nextProps.itemCount); - } - - componentDidUpdate(nextProps, nextState) { - const {offset} = this.state; - - if (nextState.offset !== offset) { - this.scrollTo(offset); - } - } - - getRef = (node) => { - this.rootNode = node; - }; - - handleScroll = (e) => { - e.preventDefault(); - - this.scrollStop(e); - }; - - scrollStop(e) { - const self = this; - const target = e.target; - - clearTimeout(self.isScrolling); - - self.isScrolling = setTimeout(() => { - self.setOnScrollStop(target); - }, SCROLL_STOP_DELAY); - } - - setOnScrollStop(target) { - const offset = this.getNodeOffset(); - if (offset < 0 || this.state.offset === offset || target !== this.rootNode) { - return; - } - - this.setState({offset}); - - const {items} = this.state; - this.setStopAndBottom(items, offset, false); - - setTimeout(() => { - this.props.onScroll(offset); - }, 0); - } - - handleLoadedItems(items) { - this.props.onLoadedItems(items); - } - - filterItems(items, itemCount) { - return items.filter((item) => item < itemCount); - } - - setStopAndBottom(items, offset, recompute, itemCount = this.props.itemCount) { - const {bottom} = this.state; - const containerSize = this.props.height; - const nextStop = this.emojiListManager.getNextStop({ - containerSize, - offset - }); - - const {start, stop} = this.emojiListManager.getVisibleRange({containerSize, offset}); - for (let i = start; i <= stop; i++) { - if (i <= itemCount && items.indexOf(i) === -1) { - items.push(i); - } - } - - this.handleLoadedItems(items); - - this.setState({ - stop: nextStop, - bottom: nextStop > bottom || recompute ? nextStop : bottom, - items - }); - } - - getNodeOffset() { - return this.rootNode.scrollTop; - } - - scrollTo(value) { - this.rootNode.scrollTop = value; - } - - getSize(index) { - const {itemSize} = this.props; - - if (typeof itemSize === 'function') { - return itemSize(index); - } - - return Array.isArray(itemSize) ? itemSize[index] : itemSize; - } - - getStyle(index) { - const style = this.styleCache[index]; - if (style) { - return style; - } - - const {size, offset} = this.emojiListManager.getSizeAndPositionForIndex(index); - - this.styleCache[index] = { - ...STYLE_ITEM, - height: size, - top: offset - }; - - return this.styleCache[index]; - } - - recomputeSizes(startIndex = 0) { - this.styleCache = {}; - this.emojiListManager.resetItem(startIndex); - } - - render() { - const {height, renderItem} = this.props; - const renderItems = this.state.items. - filter((item) => item < this.props.itemCount). - map((item) => { - return renderItem({index: item, style: this.getStyle(item)}); - }); - - return ( -
-
- {renderItems} -
-
- ); - } -} diff --git a/components/emoji_picker/emoji_list_manager.jsx b/components/emoji_picker/emoji_list_manager.jsx deleted file mode 100644 index f5705fdb07cf..000000000000 --- a/components/emoji_picker/emoji_list_manager.jsx +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -const OVERSCAN_COUNT = 10; - -export default class EmojiListManager { - constructor({itemCount, itemSizeGetter, itemSize}) { - this.itemSizeGetter = itemSizeGetter; - this.itemCount = itemCount; - this.itemSize = itemSize; - this.itemSizeAndPositionData = {}; - this.lastMeasuredIndex = -1; - } - - updateConfig({itemCount, itemSize}) { - this.itemCount = itemCount; - this.itemSize = itemSize; - } - - getLastMeasuredIndex() { - return this.lastMeasuredIndex; - } - - getSizeAndPositionForIndex(index) { - if (index < 0 || index >= this.itemCount) { - throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`); - } - - if (index > this.lastMeasuredIndex) { - const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); - let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size; - - for (var i = this.lastMeasuredIndex + 1; i <= index; i++) { - const size = this.itemSizeGetter({index: i}); - - if (size == null || isNaN(size)) { - throw Error(`Invalid size returned for index ${i} of value ${size}`); - } - - this.itemSizeAndPositionData[i] = {offset, size}; - - offset += size; - } - - this.lastMeasuredIndex = index; - } - - return this.itemSizeAndPositionData[index]; - } - - getSizeAndPositionOfLastMeasuredItem() { - return this.lastMeasuredIndex >= 0 ? - this.itemSizeAndPositionData[this.lastMeasuredIndex] : - {offset: 0, size: 0}; - } - - getTotalSize() { - const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); - const remainSize = (this.itemCount - this.lastMeasuredIndex - 1) * this.itemSize; - - return lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + remainSize; - } - - getNextStop({containerSize, offset}) { - let nextOffset = offset; - const totalSize = this.getTotalSize(); - - if (totalSize === 0) { - return 0; - } - - const maxOffset = nextOffset + containerSize; - const start = this.findNearestItem(nextOffset); - let stop = start; - const datum = this.getSizeAndPositionForIndex(start); - nextOffset = datum.offset + datum.size; - - while (nextOffset < maxOffset && stop < this.itemCount - 1) { - stop++; - nextOffset += this.getSizeAndPositionForIndex(stop).size; - } - - return Math.min(stop + OVERSCAN_COUNT, this.itemCount - 1); - } - - getVisibleRange({containerSize, offset}) { - let currentOffset = offset; - const totalSize = this.getTotalSize(); - - if (totalSize === 0) { - return {}; - } - - const maxOffset = currentOffset + containerSize; - let start = this.findNearestItem(currentOffset); - let stop = start; - const datum = this.getSizeAndPositionForIndex(start); - currentOffset = datum.offset + datum.size; - - while (currentOffset < maxOffset && stop < this.itemCount - 1) { - stop++; - currentOffset += this.getSizeAndPositionForIndex(stop).size; - } - - start = Math.max(0, start - OVERSCAN_COUNT); - stop = Math.min(stop + OVERSCAN_COUNT, this.itemCount - 1); - - return { - start, - stop - }; - } - - resetItem(index) { - this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1); - } - - searchIndex({low, high, offset}) { - let lowIndex = low; - let h = high; - const o = offset; - let middle; - let currentOffset; - - while (lowIndex <= h) { - middle = lowIndex + Math.floor((h - lowIndex) / 2); - currentOffset = this.getSizeAndPositionForIndex(middle).offset; - - if (currentOffset === o) { - return middle; - } else if (currentOffset < o) { - lowIndex = middle + 1; - } else if (currentOffset > o) { - h = middle - 1; - } - } - - return lowIndex > 0 ? lowIndex - 1 : 0; - } - - findNearestItem(offset) { - const maxOffset = Math.max(0, offset); - const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex); - - return this.searchIndex({ - high: lastMeasuredIndex, - low: 0, - offset: maxOffset - }); - } -} diff --git a/components/emoji_picker/emoji_picker.jsx b/components/emoji_picker/emoji_picker.jsx index 505e488f241a..8d2e3fd5fc89 100644 --- a/components/emoji_picker/emoji_picker.jsx +++ b/components/emoji_picker/emoji_picker.jsx @@ -3,24 +3,107 @@ import PropTypes from 'prop-types'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import {FormattedMessage} from 'react-intl'; +import throttle from 'lodash/throttle'; import EmojiStore from 'stores/emoji_store.jsx'; import * as Emoji from 'utils/emoji.jsx'; import * as Utils from 'utils/utils.jsx'; -import EmojiPickerCategory from './components/emoji_picker_category.jsx'; -import EmojiPickerItem from './components/emoji_picker_item.jsx'; -import EmojiPickerPreview from './components/emoji_picker_preview.jsx'; -import EmojiList from './emoji_list.jsx'; +import EmojiPickerCategory from './components/emoji_picker_category'; +import EmojiPickerItem from './components/emoji_picker_item'; +import EmojiPickerCategorySection from './emoji_picker_category_section'; +import EmojiPickerPreview from './components/emoji_picker_preview'; -const ROW_SIZE = 30; -const EMOJI_PER_ROW = 9; const CATEGORY_SEARCH_RESULTS = 'searchResults'; +const EMOJI_HEIGHT = 27; +const EMOJI_CONTAINER_HEIGHT = 300; +const EMOJI_CONTAINER_STYLE = { + height: EMOJI_CONTAINER_HEIGHT +}; +const EMOJI_LAZY_LOAD_BUFFER = 75; +const EMOJI_PER_ROW = 9; +const EMOJI_TO_LOAD_PER_UPDATE = 135; +const EMOJI_LAZY_LOAD_SCROLL_THROTTLE = 100; + +const CATEGORIES = { + recent: { + name: 'recent', + className: 'fa fa-clock-o', + id: 'emoji_picker.recent', + message: 'Recently Used', + offset: 0 + }, + people: { + name: 'people', + className: 'fa fa-smile-o', + id: 'emoji_picker.people', + message: 'People', + offset: 0 + }, + nature: { + name: 'nature', + className: 'fa fa-leaf', + id: 'emoji_picker.nature', + message: 'Nature', + offset: 0 + }, + foods: { + name: 'foods', + className: 'fa fa-cutlery', + id: 'emoji_picker.foods', + message: 'Foods', + offset: 0 + }, + activity: { + name: 'activity', + className: 'fa fa-futbol-o', + id: 'emoji_picker.activity', + message: 'Activity', + offset: 0 + }, + places: { + name: 'places', + className: 'fa fa-plane', + id: 'emoji_picker.places', + message: 'Places', + offset: 0 + }, + objects: { + name: 'objects', + className: 'fa fa-lightbulb-o', + id: 'emoji_picker.objects', + message: 'Objects', + offset: 0 + }, + symbols: { + name: 'symbols', + className: 'fa fa-heart-o', + id: 'emoji_picker.symbols', + message: 'Symbols', + offset: 0 + }, + flags: { + name: 'flags', + className: 'fa fa-flag-o', + id: 'emoji_picker.flags', + message: 'Flags', + offset: 0 + }, + custom: { + name: 'custom', + className: 'fa fa-at', + id: 'emoji_picker.custom', + message: 'Custom', + offset: 0 + } +}; + +function getEmojiFilename(emoji) { + return emoji.filename || emoji.id; +} -export default class EmojiPicker extends React.Component { +export default class EmojiPicker extends React.PureComponent { static propTypes = { style: PropTypes.object, rightOffset: PropTypes.number, @@ -38,380 +121,385 @@ export default class EmojiPicker extends React.Component { constructor(props) { super(props); - // All props are primitives or treated as immutable - this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this); - this.handleCategoryClick = this.handleCategoryClick.bind(this); this.handleFilterChange = this.handleFilterChange.bind(this); this.handleItemOver = this.handleItemOver.bind(this); - this.handleItemOut = this.handleItemOut.bind(this); this.handleItemClick = this.handleItemClick.bind(this); - this.handleOnScroll = this.handleOnScroll.bind(this); - this.handleItemUnmount = this.handleItemUnmount.bind(this); - - this.categories = { - recent: { - name: 'recent', - className: 'fa fa-clock-o', - id: 'emoji_picker.recent', - message: 'Recently Used', - offset: 0, - enable: false - }, - people: { - name: 'people', - className: 'fa fa-smile-o', - id: 'emoji_picker.people', - message: 'People', - offset: 0, - enable: false - }, - nature: { - name: 'nature', - className: 'fa fa-leaf', - id: 'emoji_picker.nature', - message: 'Nature', - offset: 0, - enable: false - }, - foods: { - name: 'foods', - className: 'fa fa-cutlery', - id: 'emoji_picker.foods', - message: 'Foods', - offset: 0, - enable: false - }, - activity: { - name: 'activity', - className: 'fa fa-futbol-o', - id: 'emoji_picker.activity', - message: 'Activity', - offset: 0, - enable: false - }, - places: { - name: 'places', - className: 'fa fa-plane', - id: 'emoji_picker.places', - message: 'Places', - offset: 0, - enable: false - }, - objects: { - name: 'objects', - className: 'fa fa-lightbulb-o', - id: 'emoji_picker.objects', - message: 'Objects', - offset: 0, - enable: false - }, - symbols: { - name: 'symbols', - className: 'fa fa-heart-o', - id: 'emoji_picker.symbols', - message: 'Symbols', - offset: 0, - enable: false - }, - flags: { - name: 'flags', - className: 'fa fa-flag-o', - id: 'emoji_picker.flags', - message: 'Flags', - offset: 0, - enable: false - }, - custom: { - name: 'custom', - className: 'fa fa-at', - id: 'emoji_picker.custom', - message: 'Custom', - offset: 0, - enable: false - } - }; + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleScroll = this.handleScroll.bind(this); + this.handleScrollThrottle = throttle(this.handleScroll, EMOJI_LAZY_LOAD_SCROLL_THROTTLE, {leading: false, trailing: true}); + this.updateCategoryOffset = this.updateCategoryOffset.bind(this); + this.divHeight = 0; this.state = { - activeCategory: 'recent', + allEmojis: {}, + categories: CATEGORIES, filter: '', - selected: null, - list: this.generateList(''), - scrollOffset: 0, - loadedItems: EmojiStore.getEmojiItems() || [] + cursor: [0, 0], // categoryIndex, emojiIndex + divTopOffset: 0, + emojisToShow: EMOJI_TO_LOAD_PER_UPDATE }; } + componentWillMount() { + this.getEmojis(); + } + componentDidMount() { // Delay taking focus because this briefly renders offscreen when using an Overlay // so focusing it immediately on mount can cause weird scrolling requestAnimationFrame(() => { this.searchInput.focus(); }); + this.divHeight = this.emojiPickerContainer.offsetHeight; } - handleCategoryClick(category) { - const scrollOffset = this.categories[category].offset; + componentWillUpdate(nextProps, nextState) { + if ( + this.lastVisibleEmoji && + this.state.divTopOffset !== nextState.divTopOffset + ) { + const difference = this.lastVisibleEmoji.offsetTop - (nextState.divTopOffset + EMOJI_CONTAINER_HEIGHT + EMOJI_LAZY_LOAD_BUFFER); + if (difference <= 0) { + const numToLoad = EMOJI_TO_LOAD_PER_UPDATE + Math.ceil((difference / EMOJI_HEIGHT) * EMOJI_PER_ROW * -1); + this.setState((state) => ({ + emojisToShow: state.emojisToShow + numToLoad + })); + } + } + } - this.setState({ - scrollOffset, - activeCategory: category - }); + lastVisibleEmojiRef = (lastVisibleEmoji) => { + this.lastVisibleEmoji = lastVisibleEmoji; + }; + emojiPickerContainerRef = (emojiPickerContainer) => { + this.emojiPickerContainer = emojiPickerContainer; + }; + emojiSearchInput = (input) => { + this.searchInput = input; + }; + + handleCategoryClick(categoryName) { + this.emojiPickerContainer.scrollTop = this.state.categories[categoryName].offset; } handleFilterChange(e) { e.preventDefault(); - - for (const category of Object.keys(this.categories)) { - this.categories[category].offset = 0; - this.categories[category].enable = false; - } - const filter = e.target.value; - const list = this.generateList(filter); - - let activeCategory = 'recent'; - if (!filter) { - for (const category of Object.keys(this.categories)) { - if (this.categories[category].enable) { - activeCategory = this.categories[category].name; - break; - } - } - } - - this.setState({ - activeCategory, - selected: null, - scrollOffset: 0, - list, - filter - }); + this.setState(() => ({ + filter, + cursor: [0, 0] + })); } - handleItemOver(emoji) { - clearTimeout(this.timeouthandler); + handleItemOver(categoryIndex, emojiIndex) { this.setState({ - selected: emoji + cursor: [categoryIndex, emojiIndex] }); } - handleItemOut() { - this.timeouthandler = setTimeout( - () => - this.setState({ - selected: null - }), - 500 - ); + handleItemClick(emoji) { + this.props.onEmojiClick(emoji); } - handleItemUnmount(emoji) { - // Prevent emoji preview from showing emoji which is not present anymore (due to filter) - if (this.state.selected === emoji) { - this.setState({ - selected: null - }); + handleKeyDown(e) { + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + this.selectNextEmoji(); + break; + case 'ArrowLeft': + e.preventDefault(); + this.selectPrevEmoji(); + break; + case 'ArrowUp': + e.preventDefault(); + this.selectPrevEmoji(EMOJI_PER_ROW); + break; + case 'ArrowDown': + e.preventDefault(); + this.selectNextEmoji(EMOJI_PER_ROW); + break; + case 'Enter': + e.preventDefault(); + if (this.getCurrentEmojiByCursor(this.state.cursor)) { + this.props.onEmojiClick(this.getCurrentEmojiByCursor(this.state.cursor)); + } + break; } } - handleItemClick(emoji) { - this.props.onEmojiClick(emoji); + handleScroll() { + this.setState({divTopOffset: this.emojiPickerContainer.scrollTop}); } - handleOnScroll(offset) { - let activeCategory = 'recent'; - if (this.state.filter) { - this.setState({activeCategory}); + selectNextEmoji(offset = 1) { + const {cursor} = this.state; + + // try moving to next emoji in index + let newCursor = [cursor[0], cursor[1] + offset]; + if (this.getCurrentEmojiByCursor(newCursor)) { + this.setState({cursor: newCursor}); return; } - for (const cat of Object.keys(this.categories)) { - if (offset < this.categories[cat].offset) { - break; - } - activeCategory = this.categories[cat].name; + // try moving to next category + newCursor = [cursor[0] + 1, 0]; + if (this.getCurrentEmojiByCursor(newCursor)) { + this.setState({cursor: newCursor}); } - - this.setState({activeCategory}); } - handleLoadedItems = (loadedItems = []) => { - if (!this.state.filter) { - loadedItems.sort((a, b) => a - b); - EmojiStore.saveEmojiItems(loadedItems); + selectPrevEmoji(offset = 1) { + const {cursor} = this.state; + + // try moving to prev emoji in index + let newCursor = [cursor[0], cursor[1] - offset]; + if (this.getCurrentEmojiByCursor(newCursor)) { + this.setState({cursor: newCursor}); + return; } - } - generateEmojiHeaderRow(category) { - return ( -
- -
- ); + // try moving to end of prev category + if (cursor[0] !== 0) { + const newCategory = this.getCategoryByIndex(cursor[0] - 1); + const lastVisibleEmojiInNewCategory = this.state.categories[newCategory.name].emojiIds.length - 1; + newCursor = [cursor[0] - 1, lastVisibleEmojiInNewCategory]; + if (this.getCurrentEmojiByCursor(newCursor)) { + this.setState({cursor: newCursor}); + } + } } - generateEmojiRows(emojis, category) { - return emojis.map((emoji) => { - const name = emoji.name || emoji.aliases[0]; - const key = category + '-' + name; - - return ( - - ); - }); + getCategoryByIndex(index) { + if (this.state.filter && index !== 0) { + return null; + } + return this.getCategoriesByKey(Object.keys(this.state.categories)[index]); } - addEmojiRow(list, emojiRows) { - let arr = []; - for (let i = 0; i < emojiRows.length; i += EMOJI_PER_ROW) { - arr = emojiRows.slice(i, i + EMOJI_PER_ROW); - list.push(arr); + getCurrentEmojiByCursor(cursor) { + const category = this.getCategoryByIndex(cursor[0]); + if (!category) { + return null; } - - return list; + return this.getEmojisByCategory(category)[cursor[1]]; } - getEmojis(category, filter) { - let emojis = []; - - if (category === 'recent') { - const recentEmojis = [...EmojiStore.getRecentEmojis()].reverse(); + getCategoriesByKey(key) { + return this.state.filter ? { + id: CATEGORY_SEARCH_RESULTS, + name: CATEGORY_SEARCH_RESULTS + } : this.state.categories[key]; + } - emojis = recentEmojis.filter((name) => { - return EmojiStore.has(name); - }).map((name) => { - return EmojiStore.get(name); + getEmojisByCategory(category) { + if (this.state.filter) { + return Object.values(this.state.allEmojis).filter((emoji) => { + for (let i = 0; i < emoji.aliases.length; i++) { + if (emoji.aliases[i].includes(this.state.filter)) { + return true; + } + } + return false; }); - } else { - const indices = Emoji.EmojiIndicesByCategory.get(category) || []; - - emojis = indices.map((index) => Emoji.Emojis[index]); - - if (category === 'custom') { - emojis = emojis.concat([...EmojiStore.getCustomEmojiMap().values()]); - } } - - return filter ? this.filterEmojis(emojis, filter) : emojis; + return this.state.categories[category.name].emojiIds.map((emojiId) => + this.state.allEmojis[emojiId]); } - filterEmojis(emojis, filter) { - return emojis.filter((emoji) => { - if (emoji.name) { - return emoji.name.indexOf(filter) !== -1; - } - - for (const alias of emoji.aliases) { - if (alias.indexOf(filter) !== -1) { - return true; + getEmojis() { + const {categories, allEmojis} = this.state; + for (const category of Object.keys(categories)) { + let categoryEmojis = []; + if (category === 'recent') { + const recentEmojis = [...EmojiStore.getRecentEmojis()].reverse(); + categoryEmojis = recentEmojis.filter((name) => { + return EmojiStore.has(name); + }).map((name) => { + return EmojiStore.get(name); + }); + } else { + const indices = Emoji.EmojiIndicesByCategory.get(category) || []; + categoryEmojis = indices.map((index) => Emoji.Emojis[index]); + if (category === 'custom') { + categoryEmojis = categoryEmojis.concat([...EmojiStore.getCustomEmojiMap().values()]); } } - - return false; - }); - } - - generateList(filter) { - if (filter) { - return this.generateFilteredList(filter); - } - let list = []; - - for (const category of Object.keys(this.categories)) { - const emojis = this.getEmojis(category, filter); - - if (emojis.length) { - this.categories[category].offset = list.length * ROW_SIZE; - this.categories[category].enable = true; - - const emojiHeaderRow = this.generateEmojiHeaderRow(category); - list.push(emojiHeaderRow); - - const emojiRows = this.generateEmojiRows(emojis, category); - list = this.addEmojiRow(list, emojiRows); + categories[category].emojiIds = categoryEmojis.map((emoji) => getEmojiFilename(emoji)); + for (let i = 0; i < categoryEmojis.length; i++) { + const currentEmoji = categoryEmojis[i]; + const fileName = getEmojiFilename(currentEmoji); + allEmojis[fileName] = { + ...currentEmoji, + visible: false, + offset: null + }; + if (!currentEmoji.filename) { + // if custom emoji, set proper attributes + allEmojis[fileName] = { + ...allEmojis[fileName], + aliases: [currentEmoji.name], + category: 'custom', + filename: fileName + }; + } } } - - return list; + this.setState({ + categories, + allEmojis + }); } - generateFilteredList(filter) { - let emojis = []; - - for (const category of Object.keys(this.categories)) { - if (category !== 'recent') { - emojis = emojis.concat(this.getEmojis(category, filter)); + getCurrentEmojiCategoryName() { + const categories = Object.keys(this.state.categories); + let currentCategoryName = ''; + for (let i = categories.length - 1; i >= 0; i--) { + // go through in reverse so that you get the last category that matches + const category = this.state.categories[categories[i]]; + if (this.state.divTopOffset > category.offset - 20) { + currentCategoryName = categories[i]; + break; } } - - let list = []; - if (emojis.length) { - const emojiHeaderRow = this.generateEmojiHeaderRow(CATEGORY_SEARCH_RESULTS); - list.push(emojiHeaderRow); - - const emojiRows = this.generateEmojiRows(emojis, CATEGORY_SEARCH_RESULTS); - list = this.addEmojiRow(list, emojiRows); - } - - return list; + return currentCategoryName; } emojiCategories() { - const categories = this.categories; - - const emojiPickerCategories = Object.keys(categories).map((category) => { + const categories = this.state.categories; + const currentCategoryName = this.getCurrentEmojiCategoryName(); + const emojiPickerCategories = Object.keys(categories).map((categoryName) => { + const category = categories[categoryName]; return ( } onCategoryClick={this.handleCategoryClick} - selected={this.state.activeCategory === categories[category].name} - enable={categories[category].enable} + selected={currentCategoryName === category.name} + enable={!this.state.filter} /> ); }); - return
{emojiPickerCategories}
; } - getSearchInput = (node) => { - this.searchInput = node; - }; - emojiSearch() { return (
); } + emojiCurrentResults() { + const {filter} = this.state; + const categories = filter ? [CATEGORY_SEARCH_RESULTS] : Object.keys(this.state.categories); + let numEmojisLoaded = 0; + + let categoryComponents = []; + + for (let i = 0; i < categories.length; i++) { + const category = this.getCategoriesByKey(categories[i]); + const emojis = this.getEmojisByCategory(category); + const items = this.emojiCurrentResultsItems(i, emojis, numEmojisLoaded); + numEmojisLoaded += items.length; + categoryComponents = [...categoryComponents, ( + + {items} + + )]; + } + + return ( +
+
+ {categoryComponents} +
+
+ ); + } + emojiCurrentResultsItems = (categoryIndex, emojis, currentEmojiLoadedCount) => { + const {cursor, emojisToShow} = this.state; + let numEmojisLoaded = currentEmojiLoadedCount; + + return emojis.map((emoji, emojiIndex) => { + numEmojisLoaded++; + + // set ref on first unloaded emoji + let ref; + if (numEmojisLoaded === emojisToShow) { + ref = this.lastVisibleEmojiRef; + } + if (numEmojisLoaded >= emojisToShow) { + return ( +
+ +
+ ); + } + return ( + + ); + }); + }; + + updateCategoryOffset(categoryName, offset) { + if (categoryName !== CATEGORY_SEARCH_RESULTS) { + this.setState((state) => ({ + categories: { + ...state.categories, + [categoryName]: { + ...state.categories[categoryName], + offset + } + }})); + } + } + render() { let pickerStyle; if (this.props.style && !(this.props.style.left === 0 || this.props.style.top === 0)) { @@ -426,43 +514,18 @@ export default class EmojiPicker extends React.Component { pickerStyle = {...this.props.style}; } } - if (pickerStyle && pickerStyle.top) { pickerStyle.top += this.props.topOffset; } - - let pickerClass = 'emoji-picker'; - if (this.props.placement === 'bottom') { - pickerClass += ' bottom'; - } - return (
{this.emojiCategories()} {this.emojiSearch()} - ( -
- {this.state.list[index]} -
- )} - /> - + {this.emojiCurrentResults()} +
); } diff --git a/components/emoji_picker/emoji_picker_category_section.jsx b/components/emoji_picker/emoji_picker_category_section.jsx new file mode 100644 index 000000000000..2a8f15f9c16e --- /dev/null +++ b/components/emoji_picker/emoji_picker_category_section.jsx @@ -0,0 +1,41 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +export default class EmojiPickerCategorySection extends React.PureComponent { + static propTypes = { + categoryName: PropTypes.string.isRequired, + children: PropTypes.any, + updateCategoryOffset: PropTypes.func.isRequired + }; + + componentDidMount() { + this.props.updateCategoryOffset(this.props.categoryName, this.div.offsetTop); + } + divRef = (div) => { + this.div = div; + }; + + render() { + return ( +
+
+
+ +
+
+
+ {this.props.children} +
+
+ ); + } +} diff --git a/components/emoji_picker/emoji_picker_container.jsx b/components/emoji_picker/emoji_picker_container.jsx deleted file mode 100644 index efe4dc1835af..000000000000 --- a/components/emoji_picker/emoji_picker_container.jsx +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PropTypes from 'prop-types'; -import React from 'react'; - -import EmojiStore from 'stores/emoji_store.jsx'; - -import EmojiPicker from './emoji_picker.jsx'; - -export default class EmojiPickerContainer extends React.Component { - static propTypes = { - onEmojiClick: PropTypes.func.isRequred - } - - constructor(props) { - super(props); - this.handleEmojiChange = this.handleEmojiChange.bind(this); - - this.state = { - customEmojis: EmojiStore.getCustomEmojiMap().values() ? EmojiStore.getCustomEmojiMap().values() : [] - }; - } - - componentDidMount() { - EmojiStore.addChangeListener(this.handleEmojiChange); - } - - componentWillUnount() { - EmojiStore.removeChangeListener(this.handleEmojiChange); - } - - handleEmojiChange() { - this.setState({ - customEmojis: EmojiStore.getCustomEmojiMap().values() - }); - } - - render() { - return ( - - ); - } -} diff --git a/components/file_attachment.jsx b/components/file_attachment.jsx index 11c746aced42..de43ee605a50 100644 --- a/components/file_attachment.jsx +++ b/components/file_attachment.jsx @@ -7,6 +7,8 @@ import {OverlayTrigger, Tooltip} from 'react-bootstrap'; import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils'; +import AttachmentIcon from 'components/svg/attachment_icon'; + import Constants from 'utils/constants.jsx'; import * as FileUtils from 'utils/file_utils'; import * as Utils from 'utils/utils.jsx'; @@ -157,10 +159,7 @@ export default class FileAttachment extends React.PureComponent { className='post-image__name' rel='noopener noreferrer' > - + {trimmedFilename} diff --git a/components/file_attachment_list/file_attachment_list.jsx b/components/file_attachment_list/file_attachment_list.jsx index b86989fede99..7789901619b8 100644 --- a/components/file_attachment_list/file_attachment_list.jsx +++ b/components/file_attachment_list/file_attachment_list.jsx @@ -85,7 +85,7 @@ export default class FileAttachmentList extends React.Component { key={`fileCount-${i}`} className='post-image__column post-image__column--placeholder' /> - ); + ); } } diff --git a/components/file_upload.jsx b/components/file_upload.jsx index 66b40fd400df..df271c3ac257 100644 --- a/components/file_upload.jsx +++ b/components/file_upload.jsx @@ -11,7 +11,8 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl'; import 'jquery-dragster/jquery.dragster.js'; import {uploadFile} from 'actions/file_actions.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; + +import AttachmentIcon from 'components/svg/attachment_icon'; import Constants from 'utils/constants.jsx'; import DelayedAction from 'utils/delayed_action.jsx'; @@ -19,6 +20,8 @@ import * as FileUtils from 'utils/file_utils'; import * as UserAgent from 'utils/user_agent.jsx'; import * as Utils from 'utils/utils.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + const holders = defineMessages({ limited: { id: 'file_upload.limited', @@ -390,9 +393,7 @@ class FileUpload extends React.Component { id='fileUploadButton' className='icon icon--attachment' > - + + autocompleteHint = ( +
- ), - ( -
+ ); + + autocompleteDescription = ( +
- )]; + ); } return ( @@ -588,7 +585,8 @@ export default class AbstractCommand extends React.PureComponent { - {autocompleteFields} + {autocompleteHint} + {autocompleteDescription}
this.setState({loading: false}) + () => this.setState({loading: false}) ); } } diff --git a/components/integrations/components/installed_outgoing_webhooks/installed_outgoing_webhooks.jsx b/components/integrations/components/installed_outgoing_webhooks/installed_outgoing_webhooks.jsx index 232e391c6940..c348a22eab82 100644 --- a/components/integrations/components/installed_outgoing_webhooks/installed_outgoing_webhooks.jsx +++ b/components/integrations/components/installed_outgoing_webhooks/installed_outgoing_webhooks.jsx @@ -83,12 +83,12 @@ export default class InstalledOutgoingWebhooks extends React.PureComponent { componentDidMount() { if (window.mm_config.EnableOutgoingWebhooks === 'true') { this.props.actions.getOutgoingHooks( - '', - this.props.teamId, - Constants.Integrations.START_PAGE_NUM, - Constants.Integrations.PAGE_SIZE + '', + this.props.teamId, + Constants.Integrations.START_PAGE_NUM, + Constants.Integrations.PAGE_SIZE ).then( - () => this.setState({loading: false}) + () => this.setState({loading: false}) ); } } diff --git a/components/login/components/login_mfa.jsx b/components/login/components/login_mfa.jsx index dcc3b245cb67..6fd90b2fcc8f 100644 --- a/components/login/components/login_mfa.jsx +++ b/components/login/components/login_mfa.jsx @@ -5,34 +5,69 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage} from 'react-intl'; -import * as Utils from 'utils/utils.jsx'; +import {localizeMessage} from 'utils/utils.jsx'; + +import SaveButton from 'components/save_button.jsx'; + +export default class LoginMfa extends React.PureComponent { + static propTypes = { + + /* + * User's login ID + */ + loginId: PropTypes.string.isRequired, + + /* + * User's password + */ + password: PropTypes.string.isRequired, + + /* + * Function to call when submitting user credentials + */ + submit: PropTypes.func.isRequired + }; -export default class LoginMfa extends React.Component { constructor(props) { super(props); - this.handleSubmit = this.handleSubmit.bind(this); - this.state = { + saving: false, + token: '', serverError: '' }; } - handleSubmit(e) { + handleChange = (e) => { + e.preventDefault(); + const token = e.target.value.trim().replace(/\s/g, ''); + + if (token !== this.state.token) { + this.setState({ + token + }); + } + } + + handleSubmit = (e) => { e.preventDefault(); const state = {}; - const token = this.refs.token.value.trim().replace(/\s/g, ''); - if (!token) { - state.serverError = Utils.localizeMessage('login_mfa.tokenReq', 'Please enter an MFA token'); + if (!this.state.token) { + state.serverError = localizeMessage('login_mfa.tokenReq', 'Please enter an MFA token'); this.setState(state); return; } state.serverError = ''; + state.saving = true; this.setState(state); - this.props.submit(this.props.loginId, this.props.password, token); + this.props.submit( + this.props.loginId, + this.props.password, + this.state.token + ); } render() { @@ -60,34 +95,24 @@ export default class LoginMfa extends React.Component { type='text' className='form-control' name='token' - ref='token' - placeholder={Utils.localizeMessage('login_mfa.token', 'MFA Token')} + placeholder={localizeMessage('login_mfa.token', 'MFA Token')} spellCheck='false' autoComplete='off' autoFocus={true} + onChange={this.handleChange} />
- +
); } } -LoginMfa.defaultProps = { -}; - -LoginMfa.propTypes = { - loginId: PropTypes.string.isRequired, - password: PropTypes.string.isRequired, - submit: PropTypes.func.isRequired -}; diff --git a/components/login/login_controller.jsx b/components/login/login_controller.jsx index 314c982f8471..b1a8543a20c8 100644 --- a/components/login/login_controller.jsx +++ b/components/login/login_controller.jsx @@ -361,11 +361,12 @@ export default class LoginController extends React.Component { errorClass = ' has-error'; } - let loginButton = - (); + /> + ); if (this.state.loading) { loginButton = diff --git a/components/modal_controller/modal_controller.jsx b/components/modal_controller/modal_controller.jsx index 123cbbb7e099..a2492af820e1 100644 --- a/components/modal_controller/modal_controller.jsx +++ b/components/modal_controller/modal_controller.jsx @@ -42,5 +42,4 @@ export default class ModalController extends React.Component { ); } - } diff --git a/components/navbar/navbar.jsx b/components/navbar/navbar.jsx index 0cf8809d6c0c..8021bf6ac834 100644 --- a/components/navbar/navbar.jsx +++ b/components/navbar/navbar.jsx @@ -29,7 +29,7 @@ import * as Utils from 'utils/utils.jsx'; import ChannelInfoModal from 'components/channel_info_modal'; import ChannelInviteModal from 'components/channel_invite_modal'; -import ChannelMembersModal from 'components/channel_members_modal.jsx'; +import ChannelMembersModal from 'components/channel_members_modal'; import ChannelNotificationsModal from 'components/channel_notifications_modal.jsx'; import DeleteChannelModal from 'components/delete_channel_modal'; @@ -38,6 +38,8 @@ import NotifyCounts from 'components/notify_counts.jsx'; import QuickSwitchModal from 'components/quick_switch_modal'; import RenameChannelModal from 'components/rename_channel_modal'; import StatusIcon from 'components/status_icon.jsx'; +import MenuIcon from 'components/svg/menu_icon'; +import SearchIcon from 'components/svg/search_icon'; import ToggleModalButton from 'components/toggle_modal_button.jsx'; import ToggleModalButtonRedux from 'components/toggle_modal_button_redux'; @@ -714,7 +716,6 @@ export default class Navbar extends React.Component { createCollapseButtons = (currentId) => { var buttons = []; - const menuIcon = Constants.MENU_ICON_SVG; if (currentId == null) { buttons.push( @@ -752,11 +753,7 @@ export default class Navbar extends React.Component { defaultMessage='Toggle sidebar' />
-