diff options
Diffstat (limited to 'webapp')
66 files changed, 1089 insertions, 252 deletions
diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index 183df2017..bb4721c22 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -37,6 +37,7 @@ "block-scoped-var": 2, "brace-style": [2, "1tbs", { "allowSingleLine": false }], "camelcase": [2, {"properties": "never"}], + "capitalized-comments": 0, "class-methods-use-this": 1, "comma-dangle": [2, "never"], "comma-spacing": [2, {"before": false, "after": true}], @@ -76,9 +77,11 @@ "newline-per-chained-call": 0, "no-alert": 2, "no-array-constructor": 2, + "no-await-in-loop": 2, "no-caller": 2, "no-case-declarations": 2, "no-class-assign": 2, + "no-compare-neg-zero": 2, "no-cond-assign": [2, "except-parens"], "no-confusing-arrow": 2, "no-console": 2, @@ -120,6 +123,7 @@ "no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ], "no-mixed-operators": [2, {"allowSamePrecedence": false}], "no-mixed-spaces-and-tabs": 2, + "no-multi-assign": 2, "no-multi-spaces": [2, { "exceptions": { "Property": false } }], "no-multi-str": 0, "no-multiple-empty-lines": [2, {"max": 1}], @@ -181,11 +185,14 @@ "object-shorthand": [2, "always"], "one-var": [2, "never"], "one-var-declaration-per-line": 0, + "operator-assignment": [2, "always"], "operator-linebreak": [2, "after"], "padded-blocks": [2, "never"], "prefer-arrow-callback": 2, "prefer-const": 2, + "prefer-destructuring": 0, "prefer-numeric-literals": 2, + "prefer-promise-reject-errors": 2, "prefer-rest-params": 2, "prefer-spread": 2, "prefer-template": 0, @@ -194,6 +201,7 @@ "radix": 2, "react/display-name": [2, { "ignoreTranspilerName": false }], "react/forbid-component-props": 0, + "react/forbid-elements": [2, { "forbid": ["embed"] }], "react/jsx-boolean-value": [2, "always"], "react/jsx-closing-bracket-location": [2, { "location": "tag-aligned" }], "react/jsx-curly-spacing": [2, "never"], @@ -217,6 +225,7 @@ "react/jsx-uses-react": 2, "react/jsx-uses-vars": 2, "react/jsx-wrap-multilines": 2, + "react/no-array-index-key": 1, "react/no-children-prop": 2, "react/no-danger": 0, "react/no-danger-with-children": 2, @@ -236,11 +245,13 @@ "react/prefer-es6-class": 2, "react/prefer-stateless-function": 0, "react/prop-types": 2, + "react/require-default-props": 0, "react/require-optimization": 1, "react/require-render-return": 2, "react/self-closing-comp": 2, "react/sort-comp": 0, "react/style-prop-object": 2, + "require-await": 2, "require-yield": 2, "rest-spread-spacing": [2, "never"], "semi": [2, "always"], diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index e3563d79c..acbc943cf 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -313,6 +313,7 @@ export function joinChannel(channel, success, error) { channel.id, () => { ChannelStore.removeMoreChannel(channel.id); + ChannelStore.storeChannel(channel); if (success) { success(); diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index cbcddfc7c..0c837621f 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -68,6 +68,14 @@ export function handleNewPost(post, msg) { }); } +export function pinPost(channelId, postId) { + AsyncClient.pinPost(channelId, postId); +} + +export function unpinPost(channelId, postId) { + AsyncClient.unpinPost(channelId, postId); +} + export function flagPost(postId) { trackEvent('api', 'api_posts_flagged'); AsyncClient.savePreference(Preferences.CATEGORY_FLAGGED_POST, postId, 'true'); @@ -96,7 +104,8 @@ export function getFlaggedPosts() { AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_SEARCH, results: data, - is_flagged_posts: true + is_flagged_posts: true, + is_pinned_posts: false }); loadProfilesForPosts(data.posts); @@ -107,6 +116,31 @@ export function getFlaggedPosts() { ); } +export function getPinnedPosts(channelId) { + Client.getPinnedPosts(channelId, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH_TERM, + term: null, + do_search: false, + is_mention_search: false + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH, + results: data, + is_flagged_posts: false, + is_pinned_posts: true + }); + + loadProfilesForPosts(data.posts); + }, + (err) => { + AsyncClient.dispatchError(err, 'getPinnedPosts'); + } + ); +} + export function loadPosts(channelId = ChannelStore.getCurrentId(), isPost = false) { const postList = PostStore.getAllPosts(channelId); const latestPostTime = PostStore.getLatestPostFromPageTime(channelId); diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index eaffd9ff4..a95049f93 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1802,6 +1802,15 @@ export default class Client { this.trackEvent('api', 'api_posts_get_flagged', {team_id: this.getTeamId()}); } + getPinnedPosts(channelId, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/pinned`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getPinnedPosts', success, error)); + } + getFileInfosForPost(channelId, postId, success, error) { request. get(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/get_file_infos`). @@ -2187,6 +2196,24 @@ export default class Client { }); } + pinPost(channelId, postId, success, error) { + request. + post(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/pin`). + set(this.defaultHeaders). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'pinPost', success, error)); + } + + unpinPost(channelId, postId, success, error) { + request. + post(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/unpin`). + set(this.defaultHeaders). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'unpinPost', success, error)); + } + saveReaction(channelId, reaction, success, error) { request. post(`${this.getChannelNeededRoute(channelId)}/posts/${reaction.post_id}/reactions/save`). diff --git a/webapp/components/admin_console/password_settings.jsx b/webapp/components/admin_console/password_settings.jsx index 3707977b8..43ec40904 100644 --- a/webapp/components/admin_console/password_settings.jsx +++ b/webapp/components/admin_console/password_settings.jsx @@ -39,16 +39,16 @@ export default class PasswordSettings extends AdminSettings { if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.PasswordRequirements === 'true') { let sampleErrorMsgId = 'user.settings.security.passwordError'; if (props.config.PasswordSettings.Lowercase) { - sampleErrorMsgId = sampleErrorMsgId + 'Lowercase'; + sampleErrorMsgId += 'Lowercase'; } if (props.config.PasswordSettings.Uppercase) { - sampleErrorMsgId = sampleErrorMsgId + 'Uppercase'; + sampleErrorMsgId += 'Uppercase'; } if (props.config.PasswordSettings.Number) { - sampleErrorMsgId = sampleErrorMsgId + 'Number'; + sampleErrorMsgId += 'Number'; } if (props.config.PasswordSettings.Symbol) { - sampleErrorMsgId = sampleErrorMsgId + 'Symbol'; + sampleErrorMsgId += 'Symbol'; } this.sampleErrorMsg = ( <FormattedMessage @@ -101,16 +101,16 @@ export default class PasswordSettings extends AdminSettings { } let sampleErrorMsgId = 'user.settings.security.passwordError'; if (this.refs.lowercase.checked) { - sampleErrorMsgId = sampleErrorMsgId + 'Lowercase'; + sampleErrorMsgId += 'Lowercase'; } if (this.refs.uppercase.checked) { - sampleErrorMsgId = sampleErrorMsgId + 'Uppercase'; + sampleErrorMsgId += 'Uppercase'; } if (this.refs.number.checked) { - sampleErrorMsgId = sampleErrorMsgId + 'Number'; + sampleErrorMsgId += 'Number'; } if (this.refs.symbol.checked) { - sampleErrorMsgId = sampleErrorMsgId + 'Symbol'; + sampleErrorMsgId += 'Symbol'; } return ( <FormattedMessage diff --git a/webapp/components/admin_console/reset_password_modal.jsx b/webapp/components/admin_console/reset_password_modal.jsx index 757f85517..1b9e5b37a 100644 --- a/webapp/components/admin_console/reset_password_modal.jsx +++ b/webapp/components/admin_console/reset_password_modal.jsx @@ -61,7 +61,7 @@ class ResetPasswordModal extends React.Component { if (this.state.serverError) { urlClass += ' has-error'; - serverError = <div className='form-group has-error'><p className='input__help error'>{this.state.serverError}</p></div>; + serverError = <div className='has-error'><p className='input__help error'>{this.state.serverError}</p></div>; } let title; diff --git a/webapp/components/audio_video_preview.jsx b/webapp/components/audio_video_preview.jsx index 4956900a9..9a55e4835 100644 --- a/webapp/components/audio_video_preview.jsx +++ b/webapp/components/audio_video_preview.jsx @@ -94,7 +94,6 @@ export default class AudioVideoPreview extends React.Component { <video key={this.props.fileInfo.id} ref='video' - style={{maxHeight: this.props.maxHeight}} data-setup='{}' controls='controls' width={width} @@ -111,6 +110,5 @@ export default class AudioVideoPreview extends React.Component { AudioVideoPreview.propTypes = { fileInfo: React.PropTypes.object.isRequired, - fileUrl: React.PropTypes.string.isRequired, - maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired + fileUrl: React.PropTypes.string.isRequired }; diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 01e1e98cf..120846b8d 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -30,7 +30,7 @@ import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import {getSiteURL} from 'utils/url.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; -import {getFlaggedPosts} from 'actions/post_actions.jsx'; +import {getFlaggedPosts, getPinnedPosts} from 'actions/post_actions.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; @@ -53,6 +53,7 @@ export default class ChannelHeader extends React.Component { this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this); this.handleShortcut = this.handleShortcut.bind(this); this.getFlagged = this.getFlagged.bind(this); + this.getPinnedPosts = this.getPinnedPosts.bind(this); this.initWebrtc = this.initWebrtc.bind(this); this.onBusy = this.onBusy.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); @@ -158,6 +159,15 @@ export default class ChannelHeader extends React.Component { } } + getPinnedPosts(e) { + e.preventDefault(); + if (SearchStore.isPinnedPosts) { + GlobalActions.toggleSideBarAction(false); + } else { + getPinnedPosts(this.props.channelId); + } + } + getFlagged(e) { e.preventDefault(); if (SearchStore.isFlaggedPosts) { @@ -211,6 +221,7 @@ export default class ChannelHeader extends React.Component { render() { const flagIcon = Constants.FLAG_ICON_SVG; + const pinIcon = Constants.PIN_ICON; if (!this.validState()) { // Use an empty div to make sure the header's height stays constant @@ -230,7 +241,10 @@ export default class ChannelHeader extends React.Component { ); const flaggedTooltip = ( - <Tooltip id='flaggedTooltip'> + <Tooltip + id='flaggedTooltip' + className='text-nowrap' + > <FormattedMessage id='channel_header.flagged' defaultMessage='Flagged Posts' @@ -665,19 +679,27 @@ export default class ChannelHeader extends React.Component { headerText = channel.header; } - const toggleFavoriteTooltip = ( - <Tooltip id='favoriteTooltip'> - {this.state.isFavorite ? + let toggleFavoriteTooltip; + if (this.state.isFavorite) { + toggleFavoriteTooltip = ( + <Tooltip id='favoriteTooltip'> <FormattedMessage id='channelHeader.removeFromFavorites' defaultMessage='Remove from Favorites' - /> : - <FormattedMessage - id='channelHeader.addToFavorites' - defaultMessage='Add to Favorites' - />} - </Tooltip> - ); + /> + </Tooltip> + ); + } else { + toggleFavoriteTooltip = ( + <Tooltip id='favoriteTooltip'> + <FormattedMessage + id='channelHeader.addToFavorites' + defaultMessage='Add to Favorites' + /> + </Tooltip> + ); + } + const toggleFavorite = ( <OverlayTrigger delayShow={Constants.OVERLAY_TIME_DELAY} @@ -762,8 +784,20 @@ export default class ChannelHeader extends React.Component { </OverlayTrigger> </div> </th> - <th className='header-list__members'> + <th className='header-list__right'> {popoverListMembers} + <a + href='#' + type='button' + id='pinned-posts-button' + className='pinned-posts-button' + onClick={this.getPinnedPosts} + > + <span + dangerouslySetInnerHTML={{__html: pinIcon}} + aria-hidden='true' + /> + </a> </th> <th className='search-bar__container'> <NavbarSearchBox diff --git a/webapp/components/create_team/components/display_name.jsx b/webapp/components/create_team/components/display_name.jsx index aeb8afbb9..865c0e6db 100644 --- a/webapp/components/create_team/components/display_name.jsx +++ b/webapp/components/create_team/components/display_name.jsx @@ -10,7 +10,6 @@ import logoImage from 'images/logo.png'; import React from 'react'; import ReactDOM from 'react-dom'; -import {Link} from 'react-router/es6'; import {FormattedMessage} from 'react-intl'; export default class TeamSignupDisplayNamePage extends React.Component { @@ -118,14 +117,6 @@ export default class TeamSignupDisplayNamePage extends React.Component { defaultMessage='Next' /><i className='fa fa-chevron-right'/> </button> - <div className='margin--extra'> - <Link to='/select_team'> - <FormattedMessage - id='create_team.display_name.back' - defaultMessage='Back to previous step' - /> - </Link> - </div> </form> </div> ); diff --git a/webapp/components/integrations/components/confirm_integration.jsx b/webapp/components/integrations/components/confirm_integration.jsx index 6d778f241..b4f299d1c 100644 --- a/webapp/components/integrations/components/confirm_integration.jsx +++ b/webapp/components/integrations/components/confirm_integration.jsx @@ -5,7 +5,7 @@ import React from 'react'; import BackstageHeader from 'components/backstage/components/backstage_header.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; -import {Link} from 'react-router/es6'; +import {Link, browserHistory} from 'react-router/es6'; import UserStore from 'stores/user_store.jsx'; import IntegrationStore from 'stores/integration_store.jsx'; @@ -25,6 +25,7 @@ export default class ConfirmIntegration extends React.Component { super(props); this.handleIntegrationChange = this.handleIntegrationChange.bind(this); + this.handleKeyPress = this.handleKeyPress.bind(this); const userId = UserStore.getCurrentId(); @@ -38,10 +39,12 @@ export default class ConfirmIntegration extends React.Component { componentDidMount() { IntegrationStore.addChangeListener(this.handleIntegrationChange); + window.addEventListener('keypress', this.handleKeyPress); } componentWillUnmount() { IntegrationStore.removeChangeListener(this.handleIntegrationChange); + window.removeEventListener('keypress', this.handleKeyPress); } handleIntegrationChange() { @@ -53,6 +56,12 @@ export default class ConfirmIntegration extends React.Component { }); } + handleKeyPress(e) { + if (e.key === 'Enter') { + browserHistory.push('/' + this.props.team.name + '/integrations/' + this.state.type); + } + } + render() { let headerText = null; let helpText = null; diff --git a/webapp/components/login/login_controller.jsx b/webapp/components/login/login_controller.jsx index 38c594cf7..01da0199f 100644 --- a/webapp/components/login/login_controller.jsx +++ b/webapp/components/login/login_controller.jsx @@ -413,6 +413,7 @@ export default class LoginController extends React.Component { </div> <div className='form-group'> <button + id='loginButton' type='submit' className='btn btn-primary' > diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index c945a0b9c..28d8fae05 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -19,12 +19,15 @@ import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; +import SearchStore from 'stores/search_store.jsx'; import ChannelSwitchModal from './channel_switch_modal.jsx'; import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as ChannelActions from 'actions/channel_actions.jsx'; +import * as GlobalActions from 'actions/global_actions.jsx'; +import {getPinnedPosts} from 'actions/post_actions.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -62,6 +65,7 @@ export default class Navbar extends React.Component { this.hideChannelSwitchModal = this.hideChannelSwitchModal.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); + this.getPinnedPosts = this.getPinnedPosts.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; @@ -216,6 +220,15 @@ export default class Navbar extends React.Component { }); } + getPinnedPosts(e) { + e.preventDefault(); + if (SearchStore.isPinnedPosts) { + GlobalActions.toggleSideBarAction(false); + } else { + getPinnedPosts(this.state.channel.id); + } + } + toggleFavorite = (e) => { e.preventDefault(); @@ -244,6 +257,7 @@ export default class Navbar extends React.Component { } let viewInfoOption; + let viewPinnedPostsOption; let addMembersOption; let manageMembersOption; let setChannelHeaderOption; @@ -335,6 +349,21 @@ export default class Navbar extends React.Component { </li> ); + viewPinnedPostsOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.getPinnedPosts} + > + <FormattedMessage + id='navbar.viewPinnedPosts' + defaultMessage='View Pinned Posts' + /> + </a> + </li> + ); + if (!ChannelStore.isDefault(channel)) { addMembersOption = ( <li role='presentation'> @@ -525,10 +554,10 @@ export default class Navbar extends React.Component { id='channelHeader.removeFromFavorites' defaultMessage='Remove from Favorites' /> : - <FormattedMessage - id='channelHeader.addToFavorites' - defaultMessage='Add to Favorites' - />} + <FormattedMessage + id='channelHeader.addToFavorites' + defaultMessage='Add to Favorites' + />} </a> </li> ); @@ -561,6 +590,7 @@ export default class Navbar extends React.Component { role='menu' > {viewInfoOption} + {viewPinnedPostsOption} {notificationPreferenceOption} {addMembersOption} {manageMembersOption} diff --git a/webapp/components/needs_team.jsx b/webapp/components/needs_team.jsx index 5cb714ebc..11e75bfb7 100644 --- a/webapp/components/needs_team.jsx +++ b/webapp/components/needs_team.jsx @@ -12,6 +12,7 @@ import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import PostStore from 'stores/post_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; import {startPeriodicSync, stopPeriodicSync} from 'actions/websocket_actions.jsx'; @@ -177,12 +178,25 @@ export default class NeedsTeam extends React.Component { </div> ); } + + let channel = ChannelStore.getByName(this.props.params.channel); + if (channel == null) { + // the permalink view is not really tied to a particular channel but still needs it + const postId = PostStore.getFocusedPostId(); + const post = PostStore.getEarliestPostFromPage(postId); + + // the post take some time before being available on page load + if (post != null) { + channel = ChannelStore.get(post.channel_id); + } + } + return ( <div className='channel-view'> <ErrorBar/> <WebrtcNotification/> <div className='container-fluid'> - <SidebarRight/> + <SidebarRight channel={channel}/> <SidebarRightMenu teamType={this.state.team.type}/> <WebrtcSidebar/> {content} diff --git a/webapp/components/new_channel_modal.jsx b/webapp/components/new_channel_modal.jsx index 2f9533b0e..f16b4596f 100644 --- a/webapp/components/new_channel_modal.jsx +++ b/webapp/components/new_channel_modal.jsx @@ -77,7 +77,7 @@ export default class NewChannelModal extends React.Component { e.preventDefault(); const displayName = ReactDOM.findDOMNode(this.refs.display_name).value.trim(); - if (displayName.length < 1) { + if (displayName.length < Constants.MIN_CHANNELNAME_LENGTH) { this.setState({displayNameError: true}); return; } @@ -104,7 +104,7 @@ export default class NewChannelModal extends React.Component { <p className='input__help error'> <FormattedMessage id='channel_modal.displayNameError' - defaultMessage='This field is required' + defaultMessage='Channel name must be 2 or more characters' /> {this.state.displayNameError} </p> @@ -232,7 +232,7 @@ export default class NewChannelModal extends React.Component { ref='display_name' className='form-control' placeholder={Utils.localizeMessage('channel_modal.nameEx', 'E.g.: "Bugs", "Marketing", "客户支持"')} - maxLength='22' + maxLength={Constants.MAX_CHANNELNAME_LENGTH} value={this.props.channelData.displayName} autoFocus={true} tabIndex='1' diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index bd2f744c7..1518b1ebf 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -233,7 +233,7 @@ export default class PopoverListMembers extends React.Component { } return ( - <div> + <div className='member-popover__container'> <div id='member_popover' className='member-popover__trigger' @@ -243,13 +243,11 @@ export default class PopoverListMembers extends React.Component { AsyncClient.getProfilesInChannel(this.props.channel.id, 0); }} > - <div> - {countText} - <span - className='fa fa-user' - aria-hidden='true' - /> - </div> + {countText} + <span + className='fa fa-user' + aria-hidden='true' + /> </div> <Overlay rootClose={true} diff --git a/webapp/components/post_view/components/date_separator.jsx b/webapp/components/post_view/components/date_separator.jsx new file mode 100644 index 000000000..18dc0c7ff --- /dev/null +++ b/webapp/components/post_view/components/date_separator.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {FormattedDate} from 'react-intl'; + +export default class DateSeparator extends React.Component { + render() { + return ( + <div + className='date-separator' + > + <hr className='separator__hr'/> + <div className='separator__text'> + <FormattedDate + value={this.props.date} + weekday='short' + month='short' + day='2-digit' + year='numeric' + /> + </div> + </div> + ); + } +} + +DateSeparator.propTypes = { + date: React.PropTypes.instanceOf(Date) +}; diff --git a/webapp/components/post_view/components/post_attachment.jsx b/webapp/components/post_view/components/post_attachment.jsx index 57335b94a..1b2cddcd6 100644 --- a/webapp/components/post_view/components/post_attachment.jsx +++ b/webapp/components/post_view/components/post_attachment.jsx @@ -184,6 +184,7 @@ class PostAttachment extends React.Component { author.push( <img className='attachment__author-icon' + crossOrigin='anonymous' src={data.author_icon} key={'attachment__author-icon'} height='14' @@ -257,6 +258,7 @@ class PostAttachment extends React.Component { image = ( <img className='attachment__image' + crossOrigin='anonymous' src={data.image_url} /> ); @@ -269,6 +271,7 @@ class PostAttachment extends React.Component { className='attachment__thumb-container' > <img + crossOrigin='anonymous' src={data.thumb_url} /> </div> diff --git a/webapp/components/post_view/components/post_attachment_opengraph.jsx b/webapp/components/post_view/components/post_attachment_opengraph.jsx index 12437e672..13171202a 100644 --- a/webapp/components/post_view/components/post_attachment_opengraph.jsx +++ b/webapp/components/post_view/components/post_attachment_opengraph.jsx @@ -32,7 +32,6 @@ export default class PostAttachmentOpenGraph extends React.Component { this.onImageLoad = this.onImageLoad.bind(this); this.onImageError = this.onImageError.bind(this); this.truncateText = this.truncateText.bind(this); - this.setImageWidth = this.setImageWidth.bind(this); } IMAGE_LOADED = { @@ -75,20 +74,16 @@ export default class PostAttachmentOpenGraph extends React.Component { componentDidMount() { OpenGraphStore.addUrlDataChangeListener(this.onOpenGraphMetadataChange); - this.setImageWidth(); - window.addEventListener('resize', this.setImageWidth); } componentDidUpdate() { if (this.props.childComponentDidUpdateFunction) { this.props.childComponentDidUpdateFunction(); } - this.setImageWidth(); } componentWillUnmount() { OpenGraphStore.removeUrlDataChangeListener(this.onOpenGraphMetadataChange); - window.removeEventListener('resize', this.setImageWidth); } onOpenGraphMetadataChange(url) { @@ -163,9 +158,6 @@ export default class PostAttachmentOpenGraph extends React.Component { return ( <div className='attachment__image__container--openraph' - style={{ - width: (this.imageDimentions.height * this.imageRatio) + this.smallImageContainerLeftPadding - }} // Initially set the width accordinly to max image heigh, ie 80px. Later on it would be modified according to actul height of image. ref={(div) => { this.smallImageContainer = div; }} @@ -201,6 +193,7 @@ export default class PostAttachmentOpenGraph extends React.Component { element = this.wrapInSmallImageContainer( <img className={'attachment__image attachment__image--openraph'} + crossOrigin='anonymous' src={imageUrl} ref={(img) => { this.smallImageElement = img; @@ -215,20 +208,6 @@ export default class PostAttachmentOpenGraph extends React.Component { return element; } - setImageWidth() { - if ( - this.state.imageLoaded === this.IMAGE_LOADED.YES && - this.smallImageContainer && - this.smallImageElement - ) { - this.smallImageContainer.style.width = ( - (this.smallImageElement.offsetHeight * this.imageRatio) + - this.smallImageContainerLeftPadding + - 'px' - ); - } - } - truncateText(text, maxLength = this.textMaxLenght, ellipsis = this.textEllipsis) { if (text.length > maxLength) { return text.substring(0, maxLength - ellipsis.length) + ellipsis; diff --git a/webapp/components/post_view/components/post_image.jsx b/webapp/components/post_view/components/post_image.jsx index 9a761bfca..6fe954e99 100644 --- a/webapp/components/post_view/components/post_image.jsx +++ b/webapp/components/post_view/components/post_image.jsx @@ -67,6 +67,7 @@ export default class PostImageEmbed extends React.Component { return ( <img className='img-div placeholder' + crossOrigin='anonymous' height='500px' /> ); @@ -75,6 +76,7 @@ export default class PostImageEmbed extends React.Component { return ( <img className='img-div' + crossOrigin='anonymous' src={this.props.link} /> ); diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/components/post_info.jsx index 331fdeb00..5318ec272 100644 --- a/webapp/components/post_view/components/post_info.jsx +++ b/webapp/components/post_view/components/post_info.jsx @@ -26,6 +26,8 @@ export default class PostInfo extends React.Component { this.removePost = this.removePost.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -148,6 +150,42 @@ export default class PostInfo extends React.Component { ); } + if (this.props.post.is_pinned) { + dropdownContents.push( + <li + key='unpinLink' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='post_info.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='pinLink' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='post_info.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -250,6 +288,16 @@ export default class PostInfo extends React.Component { ); } + pinPost(e) { + e.preventDefault(); + PostActions.pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + PostActions.unpinPost(this.props.post.channel_id, this.props.post.id); + } + flagPost(e) { e.preventDefault(); PostActions.flagPost(this.props.post.id); @@ -374,6 +422,18 @@ export default class PostInfo extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + return ( <ul className='post__header--info'> <li className='col'> @@ -384,6 +444,7 @@ export default class PostInfo extends React.Component { useMilitaryTime={this.props.useMilitaryTime} postId={post.id} /> + {pinnedBadge} {flagTrigger} </li> {options} diff --git a/webapp/components/post_view/components/post_time.jsx b/webapp/components/post_view/components/post_time.jsx index 25d533e0a..77f3f3266 100644 --- a/webapp/components/post_view/components/post_time.jsx +++ b/webapp/components/post_view/components/post_time.jsx @@ -40,12 +40,15 @@ export default class PostTime extends React.Component { } renderTimeTag() { + const date = getDateForUnixTicks(this.props.eventTime); + return ( <time className='post__time' - dateTime={getDateForUnixTicks(this.props.eventTime).toISOString()} + dateTime={date.toISOString()} + title={date} > - {getDateForUnixTicks(this.props.eventTime).toLocaleString('en', {hour: '2-digit', minute: '2-digit', hour12: !this.props.useMilitaryTime})} + {date.toLocaleString('en', {hour: '2-digit', minute: '2-digit', hour12: !this.props.useMilitaryTime})} </time> ); } diff --git a/webapp/components/profile_picture.jsx b/webapp/components/profile_picture.jsx index 7a5f892db..737a4400b 100644 --- a/webapp/components/profile_picture.jsx +++ b/webapp/components/profile_picture.jsx @@ -69,6 +69,7 @@ export default class ProfilePicture extends React.Component { width={this.props.width} height={this.props.width} src={this.props.src} + crossOrigin='anonymous' /> <StatusIcon status={this.props.status}/> </span> @@ -82,6 +83,7 @@ export default class ProfilePicture extends React.Component { width={this.props.width} height={this.props.width} src={this.props.src} + crossOrigin='anonymous' /> <StatusIcon status={this.props.status}/> </span> diff --git a/webapp/components/profile_popover.jsx b/webapp/components/profile_popover.jsx index c7d45474f..e21716cb3 100644 --- a/webapp/components/profile_popover.jsx +++ b/webapp/components/profile_popover.jsx @@ -182,6 +182,7 @@ export default class ProfilePopover extends React.Component { height='128' width='128' key='user-popover-image' + crossOrigin='anonymous' /> ); diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index cb527d850..52e4d9851 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -10,7 +10,7 @@ import ReactionListContainer from 'components/post_view/components/reaction_list import RhsDropdown from 'components/rhs_dropdown.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost} from 'actions/post_actions.jsx'; +import {flagPost, unflagPost, pinPost, unpinPost} from 'actions/post_actions.jsx'; import TeamStore from 'stores/team_store.jsx'; @@ -36,6 +36,8 @@ export default class RhsComment extends React.Component { this.removePost = this.removePost.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -128,6 +130,16 @@ export default class RhsComment extends React.Component { unflagPost(this.props.post.id); } + pinPost(e) { + e.preventDefault(); + pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + unpinPost(this.props.post.channel_id, this.props.post.id); + } + createDropdown() { const post = this.props.post; @@ -195,6 +207,42 @@ export default class RhsComment extends React.Component { </li> ); + if (post.is_pinned) { + dropdownContents.push( + <li + key='rhs-comment-unpin' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='rhs_root.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='rhs-comment-pin' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='rhs_root.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -503,10 +551,19 @@ export default class RhsComment extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + const timeOptions = { - day: 'numeric', - month: 'short', - year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: !this.props.useMilitaryTime @@ -524,6 +581,7 @@ export default class RhsComment extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} {flagTrigger} </li> {options} diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index 0c1037501..83d930bca 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -14,7 +14,7 @@ import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost} from 'actions/post_actions.jsx'; +import {flagPost, unflagPost, pinPost, unpinPost} from 'actions/post_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; @@ -35,6 +35,8 @@ export default class RhsRootPost extends React.Component { this.handlePermalink = this.handlePermalink.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -143,6 +145,16 @@ export default class RhsRootPost extends React.Component { ); } + pinPost(e) { + e.preventDefault(); + pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + unpinPost(this.props.post.channel_id, this.props.post.id); + } + render() { const post = this.props.post; const user = this.props.user; @@ -240,6 +252,42 @@ export default class RhsRootPost extends React.Component { </li> ); + if (post.is_pinned) { + dropdownContents.push( + <li + key='rhs-root-unpin' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='rhs_root.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='rhs-root-pin' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='rhs_root.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -450,10 +498,19 @@ export default class RhsRootPost extends React.Component { flagFunc = this.flagPost; } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + const timeOptions = { - day: 'numeric', - month: 'short', - year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: !this.props.useMilitaryTime @@ -470,6 +527,7 @@ export default class RhsRootPost extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} <OverlayTrigger key={'rootpostflagtooltipkey' + flagVisible} delayShow={Constants.OVERLAY_TIME_DELAY} diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread.jsx index 3c0b2e114..2c1d03901 100644 --- a/webapp/components/rhs_thread.jsx +++ b/webapp/components/rhs_thread.jsx @@ -8,6 +8,7 @@ import RootPost from './rhs_root_post.jsx'; import Comment from './rhs_comment.jsx'; import FileUploadOverlay from './file_upload_overlay.jsx'; import FloatingTimestamp from './post_view/components/floating_timestamp.jsx'; +import DateSeparator from './post_view/components/date_separator.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; @@ -325,6 +326,7 @@ export default class RhsThread extends React.Component { const postsArray = this.state.postsArray; const selected = this.state.selected; const profiles = this.state.profiles || {}; + let previousPostDay = Utils.getDateForUnixTicks(selected.create_at); if (postsArray == null || selected == null) { return ( @@ -355,6 +357,55 @@ export default class RhsThread extends React.Component { rootStatus = this.state.statuses[selected.user_id] || 'offline'; } + const commentsLists = []; + for (let i = 0; i < postsArray.length; i++) { + const comPost = postsArray[i]; + let p; + if (UserStore.getCurrentId() === comPost.user_id) { + p = UserStore.getCurrentUser(); + } else { + p = profiles[comPost.user_id]; + } + + let isFlagged = false; + if (this.state.flaggedPosts) { + isFlagged = this.state.flaggedPosts.get(comPost.id) === 'true'; + } + + let status = 'offline'; + if (this.state.statuses && p && p.id) { + status = this.state.statuses[p.id] || 'offline'; + } + + const keyPrefix = comPost.id ? comPost.id : comPost.pending_post_id; + + const currentPostDay = Utils.getDateForUnixTicks(comPost.create_at); + + if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { + previousPostDay = currentPostDay; + commentsLists.push( + <DateSeparator + date={currentPostDay} + />); + } + + commentsLists.push( + <div key={keyPrefix + 'commentKey'}> + <Comment + ref={comPost.id} + post={comPost} + user={p} + currentUser={this.props.currentUser} + compactDisplay={this.state.compactDisplay} + useMilitaryTime={this.props.useMilitaryTime} + isFlagged={isFlagged} + status={status} + isBusy={this.state.isBusy} + /> + </div> + ); + } + return ( <div className='post-right__container'> <FileUploadOverlay overlayType='right'/> @@ -384,6 +435,9 @@ export default class RhsThread extends React.Component { onScroll={this.handleScroll} > <div className='post-right__scroll'> + <DateSeparator + date={previousPostDay} + /> <RootPost ref={selected.id} post={selected} @@ -401,41 +455,7 @@ export default class RhsThread extends React.Component { ref='rhspostlist' className='post-right-comments-container' > - {postsArray.map((comPost) => { - let p; - if (UserStore.getCurrentId() === comPost.user_id) { - p = UserStore.getCurrentUser(); - } else { - p = profiles[comPost.user_id]; - } - - let isFlagged = false; - if (this.state.flaggedPosts) { - isFlagged = this.state.flaggedPosts.get(comPost.id) === 'true'; - } - - let status = 'offline'; - if (this.state.statuses && p && p.id) { - status = this.state.statuses[p.id] || 'offline'; - } - - const keyPrefix = comPost.id ? comPost.id : comPost.pending_post_id; - - return ( - <Comment - ref={comPost.id} - key={keyPrefix + 'commentKey'} - post={comPost} - user={p} - currentUser={this.props.currentUser} - compactDisplay={this.state.compactDisplay} - useMilitaryTime={this.props.useMilitaryTime} - isFlagged={isFlagged} - status={status} - isBusy={this.state.isBusy} - /> - ); - })} + {commentsLists} </div> <div className='post-create__container'> <CreateComment diff --git a/webapp/components/search_bar.jsx b/webapp/components/search_bar.jsx index 1c9f607e6..b88e67a11 100644 --- a/webapp/components/search_bar.jsx +++ b/webapp/components/search_bar.jsx @@ -216,7 +216,10 @@ export default class SearchBar extends React.Component { ); const flaggedTooltip = ( - <Tooltip id='flaggedTooltip'> + <Tooltip + id='flaggedTooltip' + className='text-nowrap' + > <FormattedMessage id='channel_header.flagged' defaultMessage='Flagged Posts' diff --git a/webapp/components/search_results.jsx b/webapp/components/search_results.jsx index 4c0105738..ceafd766c 100644 --- a/webapp/components/search_results.jsx +++ b/webapp/components/search_results.jsx @@ -213,6 +213,31 @@ export default class SearchResults extends React.Component { </ul> </div> ); + } else if (this.props.isPinnedPosts && noResults) { + ctls = ( + <div className='sidebar--right__subheader'> + <ul> + <li> + <FormattedHTMLMessage + id='search_results.usagePin1' + defaultMessage='There are no pinned messages yet.' + /> + </li> + <li> + <FormattedHTMLMessage + id='search_results.usagePin2' + defaultMessage={'You can pin a message by clicking the "Pin to channel" option from the message\'s menu.'} + /> + </li> + <li> + <FormattedHTMLMessage + id='search_results.usagePin3' + defaultMessage='Pinned messages are accessible by all channel members and are a way to mark messages for future reference.' + /> + </li> + </ul> + </div> + ); } else if (!searchTerm && noResults) { ctls = ( <div className='sidebar--right__subheader'> @@ -289,6 +314,8 @@ export default class SearchResults extends React.Component { toggleSize={this.props.toggleSize} shrink={this.props.shrink} isFlaggedPosts={this.props.isFlaggedPosts} + isPinnedPosts={this.props.isPinnedPosts} + channelDisplayName={this.props.channelDisplayName} /> <div id='search-items-container' @@ -307,5 +334,7 @@ SearchResults.propTypes = { useMilitaryTime: React.PropTypes.bool.isRequired, toggleSize: React.PropTypes.func, shrink: React.PropTypes.func, - isFlaggedPosts: React.PropTypes.bool + isFlaggedPosts: React.PropTypes.bool, + isPinnedPosts: React.PropTypes.bool, + channelDisplayName: React.PropTypes.string.isRequired }; diff --git a/webapp/components/search_results_header.jsx b/webapp/components/search_results_header.jsx index 1f2818e98..288d883ee 100644 --- a/webapp/components/search_results_header.jsx +++ b/webapp/components/search_results_header.jsx @@ -79,6 +79,16 @@ export default class SearchResultsHeader extends React.Component { defaultMessage='Flagged Posts' /> ); + } else if (this.props.isPinnedPosts) { + title = ( + <FormattedMessage + id='search_header.title4' + defaultMessage='Pinned posts in {channelDisplayName}' + values={{ + channelDisplayName: this.props.channelDisplayName + }} + /> + ); } return ( @@ -131,5 +141,7 @@ SearchResultsHeader.propTypes = { isMentionSearch: React.PropTypes.bool, toggleSize: React.PropTypes.func, shrink: React.PropTypes.func, - isFlaggedPosts: React.PropTypes.bool + isFlaggedPosts: React.PropTypes.bool, + isPinnedPosts: React.PropTypes.bool, + channelDisplayName: React.PropTypes.string.isRequired }; diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index b3de3492c..1c7309f51 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -289,6 +289,18 @@ export default class SearchResultsItem extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + return ( <div className='search-item__container'> <div className='date-separator'> @@ -322,6 +334,7 @@ export default class SearchResultsItem extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post)} + {pinnedBadge} {flagContent} </li> {rhsControls} diff --git a/webapp/components/searchable_user_list.jsx b/webapp/components/searchable_user_list.jsx index d25c8a506..ab3f9ee9b 100644 --- a/webapp/components/searchable_user_list.jsx +++ b/webapp/components/searchable_user_list.jsx @@ -19,6 +19,7 @@ export default class SearchableUserList extends React.Component { this.nextPage = this.nextPage.bind(this); this.previousPage = this.previousPage.bind(this); this.doSearch = this.doSearch.bind(this); + this.focusSearchBar = this.focusSearchBar.bind(this); this.nextTimeoutId = 0; @@ -30,15 +31,14 @@ export default class SearchableUserList extends React.Component { } componentDidMount() { - if (this.props.focusOnMount) { - this.refs.filter.focus(); - } + this.focusSearchBar(); } componentDidUpdate(prevProps, prevState) { if (this.state.page !== prevState.page) { $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0); } + this.focusSearchBar(); } componentWillUnmount() { @@ -57,6 +57,12 @@ export default class SearchableUserList extends React.Component { this.setState({page: this.state.page - 1}); } + focusSearchBar() { + if (this.props.focusOnMount) { + this.refs.filter.focus(); + } + } + doSearch() { const term = this.refs.filter.value; this.props.search(term); diff --git a/webapp/components/setting_item_max.jsx b/webapp/components/setting_item_max.jsx index 5b6a5d53a..9f3c4f0cf 100644 --- a/webapp/components/setting_item_max.jsx +++ b/webapp/components/setting_item_max.jsx @@ -31,12 +31,30 @@ export default class SettingItemMax extends React.Component { render() { var clientError = null; if (this.props.client_error) { - clientError = (<div className='form-group'><label className='col-sm-12 has-error'>{this.props.client_error}</label></div>); + clientError = ( + <div className='form-group'> + <label + id='clientError' + className='col-sm-12 has-error' + > + {this.props.client_error} + </label> + </div> + ); } var serverError = null; if (this.props.server_error) { - serverError = (<div className='form-group'><label className='col-sm-12 has-error'>{this.props.server_error}</label></div>); + serverError = ( + <div className='form-group'> + <label + id='serverError' + className='col-sm-12 has-error' + > + {this.props.server_error} + </label> + </div> + ); } var extraInfo = null; @@ -48,6 +66,7 @@ export default class SettingItemMax extends React.Component { if (this.props.submit) { submit = ( <input + id='saveSetting' type='submit' className='btn btn-sm btn-primary' href='#' @@ -88,6 +107,7 @@ export default class SettingItemMax extends React.Component { {clientError} {submit} <a + id={this.props.title + 'Cancel'} className='btn btn-sm' href='#' onClick={this.props.updateSection} diff --git a/webapp/components/setting_item_min.jsx b/webapp/components/setting_item_min.jsx index 96d8bf459..4f756c46e 100644 --- a/webapp/components/setting_item_min.jsx +++ b/webapp/components/setting_item_min.jsx @@ -12,6 +12,7 @@ export default class SettingItemMin extends React.Component { editButton = ( <li className='col-xs-12 col-sm-3 section-edit'> <a + id={this.props.title} className='theme' href='#' onClick={this.props.updateSection} @@ -33,7 +34,12 @@ export default class SettingItemMin extends React.Component { > <li className='col-xs-12 col-sm-9 section-title'>{this.props.title}</li> {editButton} - <li className='col-xs-12 section-describe'>{this.props.describe}</li> + <li + id={this.props.title + 'Desc'} + className='col-xs-12 section-describe' + > + {this.props.describe} + </li> </ul> ); } diff --git a/webapp/components/setting_picture.jsx b/webapp/components/setting_picture.jsx index b74ee8eb7..45ac4096d 100644 --- a/webapp/components/setting_picture.jsx +++ b/webapp/components/setting_picture.jsx @@ -1,8 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; -import ReactDOM from 'react-dom'; import {FormattedMessage} from 'react-intl'; import loadingGif from 'images/load.gif'; @@ -14,17 +12,35 @@ export default class SettingPicture extends React.Component { super(props); this.setPicture = this.setPicture.bind(this); + this.confirmImage = this.confirmImage.bind(this); } setPicture(file) { if (file) { var reader = new FileReader(); - var img = ReactDOM.findDOMNode(this.refs.image); - reader.onload = function load(e) { - $(img).attr('src', e.target.result); - }; + reader.onload = (e) => { + const canvas = this.refs.profileImageCanvas; + const context = canvas.getContext('2d'); + const imageObj = new Image(); + imageObj.onload = () => { + if (imageObj.width > imageObj.height) { + const side = imageObj.height; + const rem = imageObj.width - side; + const startX = parseInt(rem / 2, 10); + context.drawImage(imageObj, startX, 0, side, side, + 0, 0, canvas.width, canvas.height); + } else { + const side = imageObj.width; + const rem = imageObj.height - side; + const startY = parseInt(rem / 2, 10); + context.drawImage(imageObj, 0, startY, side, side, + 0, 0, canvas.width, canvas.height); + } + }; + imageObj.src = e.target.result; + }; reader.readAsDataURL(file); } } @@ -48,10 +64,11 @@ export default class SettingPicture extends React.Component { var img = null; if (this.props.picture) { img = ( - <img - ref='image' - className='profile-img rounded' - src='' + <canvas + ref='profileImageCanvas' + className='profile-img' + width='256px' + height='256px' /> ); } else { @@ -83,7 +100,7 @@ export default class SettingPicture extends React.Component { confirmButton = ( <a className={confirmButtonClass} - onClick={this.props.submit} + onClick={this.confirmImage} > <FormattedMessage id='setting_picture.save' @@ -147,6 +164,16 @@ export default class SettingPicture extends React.Component { </ul> ); } + + confirmImage(e) { + e.persist(); + this.refs.profileImageCanvas.toBlob((blob) => { + blob.lastModifiedDate = new Date(); + blob.name = 'image.jpg'; + this.props.imageCropChange(blob); + this.props.submit(e); + }, 'image/jpeg', 0.95); + } } SettingPicture.propTypes = { @@ -158,5 +185,6 @@ SettingPicture.propTypes = { submitActive: React.PropTypes.bool, submit: React.PropTypes.func, title: React.PropTypes.string, - pictureChange: React.PropTypes.func + pictureChange: React.PropTypes.func, + imageCropChange: React.PropTypes.func }; diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index 08d80d363..b9356c5a1 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -636,13 +636,15 @@ export default class Sidebar extends React.Component { this.lastUnreadChannel = null; // create elements for all 4 types of channels - const favoriteItems = this.state.favoriteChannels.map((channel, index, arr) => { - if (channel.type === Constants.DM_CHANNEL) { - return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); - } + const favoriteItems = this.state.favoriteChannels. + sort(Utils.sortTeamsByDisplayName). + map((channel, index, arr) => { + if (channel.type === Constants.DM_CHANNEL) { + return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); + } - return this.createChannelElement(channel); - }); + return this.createChannelElement(channel); + }); const publicChannelItems = this.state.publicChannels.map(this.createChannelElement); diff --git a/webapp/components/sidebar_header_dropdown.jsx b/webapp/components/sidebar_header_dropdown.jsx index 484ca3298..34c228ac2 100644 --- a/webapp/components/sidebar_header_dropdown.jsx +++ b/webapp/components/sidebar_header_dropdown.jsx @@ -467,6 +467,7 @@ export default class SidebarHeaderDropdown extends React.Component { <Dropdown.Menu> <li> <a + id='accountSettings' href='#' onClick={this.toggleAccountSettingsModal} > @@ -480,6 +481,7 @@ export default class SidebarHeaderDropdown extends React.Component { {teamLink} <li> <a + id='logout' href='#' onClick={() => GlobalActions.emitUserLoggedOutEvent()} > diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx index fb120337a..42b7381f4 100644 --- a/webapp/components/sidebar_right.jsx +++ b/webapp/components/sidebar_right.jsx @@ -11,13 +11,13 @@ import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import WebrtcStore from 'stores/webrtc_store.jsx'; -import {getFlaggedPosts} from 'actions/post_actions.jsx'; +import {getFlaggedPosts, getPinnedPosts} from 'actions/post_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; -import React from 'react'; +import React, {PropTypes} from 'react'; export default class SidebarRight extends React.Component { constructor(props) { @@ -27,6 +27,7 @@ export default class SidebarRight extends React.Component { this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onSelectedChange = this.onSelectedChange.bind(this); + this.onPostPinnedChange = this.onPostPinnedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); this.onUserChange = this.onUserChange.bind(this); this.onShowSearch = this.onShowSearch.bind(this); @@ -39,6 +40,7 @@ export default class SidebarRight extends React.Component { searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch(), isFlaggedPosts: SearchStore.getIsFlaggedPosts(), + isPinnedPosts: SearchStore.getIsPinnedPosts(), postRightVisible: Boolean(PostStore.getSelectedPost()), expanded: false, fromSearch: false, @@ -50,6 +52,7 @@ export default class SidebarRight extends React.Component { componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); + PostStore.addPostPinnedChangeListener(this.onPostPinnedChange); SearchStore.addShowSearchListener(this.onShowSearch); UserStore.addChangeListener(this.onUserChange); PreferenceStore.addChangeListener(this.onPreferenceChange); @@ -59,6 +62,7 @@ export default class SidebarRight extends React.Component { componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); + PostStore.removePostPinnedChangeListener(this.onPostPinnedChange); SearchStore.removeShowSearchListener(this.onShowSearch); UserStore.removeChangeListener(this.onUserChange); PreferenceStore.removeChangeListener(this.onPreferenceChange); @@ -137,6 +141,12 @@ export default class SidebarRight extends React.Component { }); } + onPostPinnedChange() { + if (this.props.channel && this.state.isPinnedPosts) { + getPinnedPosts(this.props.channel.id); + } + } + onShrink() { this.setState({ expanded: false @@ -147,7 +157,8 @@ export default class SidebarRight extends React.Component { this.setState({ searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch(), - isFlaggedPosts: SearchStore.getIsFlaggedPosts() + isFlaggedPosts: SearchStore.getIsFlaggedPosts(), + isPinnedPosts: SearchStore.getIsPinnedPosts() }); } @@ -182,9 +193,11 @@ export default class SidebarRight extends React.Component { <SearchResults isMentionSearch={this.state.isMentionSearch} isFlaggedPosts={this.state.isFlaggedPosts} + isPinnedPosts={this.state.isPinnedPosts} useMilitaryTime={this.state.useMilitaryTime} toggleSize={this.toggleSize} shrink={this.onShrink} + channelDisplayName={this.props.channel ? this.props.channel.display_name : ''} /> ); } else if (this.state.postRightVisible) { @@ -222,3 +235,7 @@ export default class SidebarRight extends React.Component { ); } } + +SidebarRight.propTypes = { + channel: PropTypes.object +}; diff --git a/webapp/components/team_general_tab.jsx b/webapp/components/team_general_tab.jsx index 0100cad64..bc6c70e7f 100644 --- a/webapp/components/team_general_tab.jsx +++ b/webapp/components/team_general_tab.jsx @@ -106,14 +106,11 @@ class GeneralTab extends React.Component { let valid = true; const name = this.state.name.trim(); - if (!name) { + if (name) { + state.clientError = ''; + } else { state.clientError = Utils.localizeMessage('general_tab.required', 'This field is required'); valid = false; - } else if (name === this.props.team.display_name) { - state.clientError = Utils.localizeMessage('general_tab.chooseName', 'Please choose a new name for your team'); - valid = false; - } else { - state.clientError = ''; } this.setState(state); diff --git a/webapp/components/user_settings/desktop_notification_settings.jsx b/webapp/components/user_settings/desktop_notification_settings.jsx index 3a330b623..be403ebb6 100644 --- a/webapp/components/user_settings/desktop_notification_settings.jsx +++ b/webapp/components/user_settings/desktop_notification_settings.jsx @@ -74,6 +74,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundOn' type='radio' name='notificationSounds' checked={soundRadio[0]} @@ -89,6 +90,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundOff' type='radio' name='notificationSounds' checked={soundRadio[1]} @@ -136,6 +138,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundDuration3' type='radio' name='desktopDuration' checked={durationRadio[0]} @@ -154,6 +157,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundDuration5' type='radio' name='desktopDuration' checked={durationRadio[1]} @@ -172,6 +176,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundDuration10' type='radio' name='desktopDuration' checked={durationRadio[2]} @@ -189,6 +194,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='soundDurationUnlimited' type='radio' name='desktopDuration' checked={durationRadio[3]} @@ -225,6 +231,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='desktopNotificationAllActivity' type='radio' name='desktopNotificationLevel' checked={activityRadio[0]} @@ -240,6 +247,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='desktopNotificationMentions' type='radio' name='desktopNotificationLevel' checked={activityRadio[1]} @@ -255,6 +263,7 @@ export default class DesktopNotificationSettings extends React.Component { <div className='radio'> <label> <input + id='desktopNotificationNever' type='radio' name='desktopNotificationLevel' checked={activityRadio[2]} diff --git a/webapp/components/user_settings/email_notification_setting.jsx b/webapp/components/user_settings/email_notification_setting.jsx index 457512507..1e6c5d7f5 100644 --- a/webapp/components/user_settings/email_notification_setting.jsx +++ b/webapp/components/user_settings/email_notification_setting.jsx @@ -113,6 +113,7 @@ export default class EmailNotificationSetting extends React.Component { <div className='radio'> <label> <input + id='emailNotificationMinutes' type='radio' name='emailNotifications' checked={this.props.enableEmail && this.state.emailInterval === Preferences.INTERVAL_FIFTEEN_MINUTES} @@ -128,6 +129,7 @@ export default class EmailNotificationSetting extends React.Component { <div className='radio'> <label> <input + id='emailNotificationHour' type='radio' name='emailNotifications' checked={this.props.enableEmail && this.state.emailInterval === Preferences.INTERVAL_HOUR} @@ -164,6 +166,7 @@ export default class EmailNotificationSetting extends React.Component { <div className='radio'> <label> <input + id='emailNotificationImmediately' type='radio' name='emailNotifications' checked={this.props.enableEmail && this.state.emailInterval === Preferences.INTERVAL_IMMEDIATE} @@ -179,6 +182,7 @@ export default class EmailNotificationSetting extends React.Component { <div className='radio'> <label> <input + id='emailNotificationNever' type='radio' name='emailNotifications' checked={!this.props.enableEmail} diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx index f9c624aa0..d558958f0 100644 --- a/webapp/components/user_settings/user_settings_general.jsx +++ b/webapp/components/user_settings/user_settings_general.jsx @@ -101,6 +101,7 @@ class UserSettingsGeneralTab extends React.Component { this.updatePicture = this.updatePicture.bind(this); this.updateSection = this.updateSection.bind(this); this.updatePosition = this.updatePosition.bind(this); + this.updatedCroppedPicture = this.updatedCroppedPicture.bind(this); this.state = this.setupInitialState(props); } @@ -311,6 +312,17 @@ class UserSettingsGeneralTab extends React.Component { this.setState({confirmEmail: e.target.value}); } + updatedCroppedPicture(file) { + if (file) { + this.setState({picture: file}); + + this.submitActive = true; + this.setState({clientError: null}); + } else { + this.setState({picture: null}); + } + } + updatePicture(e) { if (e.target.files && e.target.files[0]) { this.setState({picture: e.target.files[0]}); @@ -410,6 +422,7 @@ class UserSettingsGeneralTab extends React.Component { </label> <div className='col-sm-7'> <input + id='primaryEmail' className='form-control' type='email' onChange={this.updateEmail} @@ -431,6 +444,7 @@ class UserSettingsGeneralTab extends React.Component { </label> <div className='col-sm-7'> <input + id='confirmEmail' className='form-control' type='email' onChange={this.updateConfirmEmail} @@ -684,6 +698,7 @@ class UserSettingsGeneralTab extends React.Component { </label> <div className='col-sm-7'> <input + id='firstName' className='form-control' type='text' onChange={this.updateFirstName} @@ -706,6 +721,7 @@ class UserSettingsGeneralTab extends React.Component { </label> <div className='col-sm-7'> <input + id='lastName' className='form-control' type='text' onChange={this.updateLastName} @@ -832,6 +848,7 @@ class UserSettingsGeneralTab extends React.Component { <label className='col-sm-5 control-label'>{nicknameLabel}</label> <div className='col-sm-7'> <input + id='nickname' className='form-control' type='text' onChange={this.updateNickname} @@ -916,6 +933,7 @@ class UserSettingsGeneralTab extends React.Component { <label className='col-sm-5 control-label'>{usernameLabel}</label> <div className='col-sm-7'> <input + id='username' maxLength={Constants.MAX_USERNAME_LENGTH} className='form-control' type='text' @@ -1006,6 +1024,7 @@ class UserSettingsGeneralTab extends React.Component { <label className='col-sm-5 control-label'>{positionLabel}</label> <div className='col-sm-7'> <input + id='position' className='form-control' type='text' onChange={this.updatePosition} @@ -1086,6 +1105,7 @@ class UserSettingsGeneralTab extends React.Component { pictureChange={this.updatePicture} submitActive={this.submitActive} loadingPicture={this.state.loadingPicture} + imageCropChange={this.updatedCroppedPicture} /> ); } else { @@ -1123,6 +1143,7 @@ class UserSettingsGeneralTab extends React.Component { <div> <div className='modal-header'> <button + id='closeUserSettings' type='button' className='close' data-dismiss='modal' diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx index 7c82488f6..ebd43e5af 100644 --- a/webapp/components/user_settings/user_settings_notifications.jsx +++ b/webapp/components/user_settings/user_settings_notifications.jsx @@ -64,6 +64,9 @@ function getNotificationsStateFromStores() { } else { usernameKey = true; keys.splice(keys.indexOf(user.username), 1); + if (keys.indexOf(`@${user.username}`) !== -1) { + keys.splice(keys.indexOf(`@${user.username}`), 1); + } } customKeys = keys.join(','); @@ -281,6 +284,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationOnline' type='radio' name='pushNotificationStatus' checked={pushStatusRadio[0]} @@ -296,6 +300,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationAway' type='radio' name='pushNotificationStatus' checked={pushStatusRadio[1]} @@ -311,6 +316,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationOffline' type='radio' name='pushNotificationStatus' checked={pushStatusRadio[2]} @@ -347,6 +353,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationAllActivity' type='radio' name='pushNotificationLevel' checked={pushActivityRadio[0]} @@ -362,6 +369,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationMentions' type='radio' name='pushNotificationLevel' checked={pushActivityRadio[1]} @@ -377,6 +385,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='pushNotificationNever' type='radio' name='pushNotificationLevel' checked={pushActivityRadio[2]} @@ -520,6 +529,7 @@ export default class NotificationsTab extends React.Component { <div className='checkbox'> <label> <input + id='notificationTriggerFirst' type='checkbox' checked={this.state.firstNameKey} onChange={handleUpdateFirstNameKey} @@ -545,6 +555,7 @@ export default class NotificationsTab extends React.Component { <div className='checkbox'> <label> <input + id='notificationTriggerUsername' type='checkbox' checked={this.state.usernameKey} onChange={handleUpdateUsernameKey} @@ -569,6 +580,7 @@ export default class NotificationsTab extends React.Component { <div className='checkbox'> <label> <input + id='notificationTriggerShouts' type='checkbox' checked={this.state.channelKey} onChange={handleUpdateChannelKey} @@ -587,6 +599,7 @@ export default class NotificationsTab extends React.Component { <div className='checkbox'> <label> <input + id='notificationTriggerCustom' ref='customcheck' type='checkbox' checked={this.state.customKeysChecked} @@ -599,6 +612,7 @@ export default class NotificationsTab extends React.Component { </label> </div> <input + id='notificationTriggerCustomText' ref='custommentions' className='form-control mentions-input' type='text' @@ -697,6 +711,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='notificationCommentsAny' type='radio' name='commentsNotificationLevel' checked={commentsActive[0]} @@ -712,6 +727,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='notificationCommentsRoot' type='radio' name='commentsNotificationLevel' checked={commentsActive[1]} @@ -727,6 +743,7 @@ export default class NotificationsTab extends React.Component { <div className='radio'> <label> <input + id='notificationCommentsNever' type='radio' name='commentsNotificationLevel' checked={commentsActive[2]} @@ -804,6 +821,7 @@ export default class NotificationsTab extends React.Component { <div> <div className='modal-header'> <button + id='closeButton' type='button' className='close' data-dismiss='modal' diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx index b6ee2d915..9ca7f4b62 100644 --- a/webapp/components/user_settings/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -331,6 +331,7 @@ export default class SecurityTab extends React.Component { </label> <div className='col-sm-7'> <input + id='currentPassword' className='form-control' type='password' onChange={this.updateCurrentPassword} @@ -352,6 +353,7 @@ export default class SecurityTab extends React.Component { </label> <div className='col-sm-7'> <input + id='newPassword' className='form-control' type='password' onChange={this.updateNewPassword} @@ -373,6 +375,7 @@ export default class SecurityTab extends React.Component { </label> <div className='col-sm-7'> <input + id='confirmPassword' className='form-control' type='password' onChange={this.updateConfirmPassword} diff --git a/webapp/components/view_image.jsx b/webapp/components/view_image.jsx index 385138d54..e5c3caa0a 100644 --- a/webapp/components/view_image.jsx +++ b/webapp/components/view_image.jsx @@ -185,7 +185,6 @@ export default class ViewImageModal extends React.Component { <ImagePreview fileInfo={fileInfo} fileUrl={fileUrl} - maxHeight={this.state.imgHeight} /> ); } else if (fileType === 'video' || fileType === 'audio') { @@ -193,7 +192,6 @@ export default class ViewImageModal extends React.Component { <AudioVideoPreview fileInfo={fileInfo} fileUrl={fileUrl} - maxHeight={this.state.imgHeight} /> ); } else if (PDFPreview.supports(fileInfo)) { @@ -344,7 +342,7 @@ LoadingImagePreview.propTypes = { loading: React.PropTypes.string }; -function ImagePreview({fileInfo, fileUrl, maxHeight}) { +function ImagePreview({fileInfo, fileUrl}) { let previewUrl; if (fileInfo.has_preview_image) { previewUrl = FileStore.getFilePreviewUrl(fileInfo.id); @@ -359,16 +357,12 @@ function ImagePreview({fileInfo, fileUrl, maxHeight}) { rel='noopener noreferrer' download={true} > - <img - style={{maxHeight}} - src={previewUrl} - /> + <img src={previewUrl}/> </a> ); } ImagePreview.propTypes = { fileInfo: React.PropTypes.object.isRequired, - fileUrl: React.PropTypes.string.isRequired, - maxHeight: React.PropTypes.number.isRequired + fileUrl: React.PropTypes.string.isRequired }; diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 3ef467937..bc30b53e7 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1118,7 +1118,7 @@ "channel_modal.channel": "Channel", "channel_modal.createNew": "Create New ", "channel_modal.descriptionHelp": "Describe how this {term} should be used.", - "channel_modal.displayNameError": "This field is required", + "channel_modal.displayNameError": "Channel name must be 2 or more characters", "channel_modal.edit": "Edit", "channel_modal.group": "Group", "channel_modal.header": "Header", @@ -1208,7 +1208,6 @@ "create_post.tutorialTip": "<h4>Sending Messages</h4><p>Type here to write a message and press <strong>ENTER</strong> to post it.</p><p>Click the <strong>Attachment</strong> button to upload an image or a file.</p>", "create_post.write": "Write a message...", "create_team.agreement": "By proceeding to create your account and use {siteName}, you agree to our <a href={TermsOfServiceLink}>Terms of Service</a> and <a href={PrivacyPolicyLink}>Privacy Policy</a>. If you do not agree, you cannot use {siteName}.", - "create_team.display_name.back": "Back to previous step", "create_team.display_name.charLength": "Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description later.", "create_team.display_name.nameHelp": "Name your team in any language. Your team name shows in menus and headings.", "create_team.display_name.next": "Next", @@ -1324,7 +1323,6 @@ "flag_post.flag": "Flag for follow up", "flag_post.unflag": "Unflag", "general_tab.chooseDescription": "Please choose a new description for your team", - "general_tab.chooseName": "Please choose a new name for your team", "general_tab.codeDesc": "Click 'Edit' to regenerate Invite Code.", "general_tab.codeLongDesc": "The Invite Code is used as part of the URL in the team invitation link created by {getTeamInviteLink} in the main menu. Regenerating creates a new team invitation link and invalidates the previous link.", "general_tab.codeTitle": "Invite Code", @@ -1665,6 +1663,7 @@ "navbar.toggle1": "Toggle sidebar", "navbar.toggle2": "Toggle sidebar", "navbar.viewInfo": "View Info", + "navbar.viewPinnedPosts": "View Pinned Posts", "navbar_dropdown.about": "About Mattermost", "navbar_dropdown.accountSettings": "Account Settings", "navbar_dropdown.console": "System Console", @@ -1721,6 +1720,9 @@ "post_info.permalink": "Permalink", "post_info.reply": "Reply", "post_info.system": "System", + "post_info.pin": "Pin to channel", + "post_info.unpin": "Un-pin from channel", + "post_info.pinned": "Pinned", "post_message_view.edited": "(edited)", "posts_view.loadMore": "Load more messages", "posts_view.newMsg": "New Messages", @@ -1773,11 +1775,14 @@ "rhs_root.mobile.flag": "Flag", "rhs_root.mobile.unflag": "Unflag", "rhs_root.permalink": "Permalink", + "rhs_root.pin": "Pin to channel", + "rhs_root.unpin": "Un-pin from channel", "search_bar.search": "Search", "search_bar.usage": "<h4>Search Options</h4><ul><li><span>Use </span><b>\"quotation marks\"</b><span> to search for phrases</span></li><li><span>Use </span><b>from:</b><span> to find posts from specific users and </span><b>in:</b><span> to find posts in specific channels</span></li></ul>", "search_header.results": "Search Results", "search_header.title2": "Recent Mentions", "search_header.title3": "Flagged Posts", + "search_header.title4": "Pinned posts in {channelDisplayName}", "search_item.direct": "Direct Message (with {username})", "search_item.jump": "Jump", "search_results.because": "<ul><li>If you're searching a partial phrase (ex. searching \"rea\", looking for \"reach\" or \"reaction\"), append a * to your search term.</li><li>Two letter searches and common words like \"this\", \"a\" and \"is\" won't appear in search results due to excessive results returned.</li></ul>", @@ -1787,6 +1792,9 @@ "search_results.usageFlag2": "You can add a flag to messages and comments by clicking the ", "search_results.usageFlag3": " icon next to the timestamp.", "search_results.usageFlag4": "Flags are a way to mark messages for follow up. Your flags are personal, and cannot be seen by other users.", + "search_results.usagePin1": "There are no pinned messages yet.", + "search_results.usagePin2": "You can pin a message by clicking the \"Pin to channel\" option from the message's menu.", + "search_results.usagePin3": "Pinned messages are accessible by all channel members and are a way to mark messages for future reference.", "setting_item_max.cancel": "Cancel", "setting_item_max.save": "Save", "setting_item_min.edit": "Edit", @@ -2238,6 +2246,8 @@ "user.settings.security.switchSaml": "Switch to using SAML SSO", "user.settings.security.title": "Security Settings", "user.settings.security.viewHistory": "View Access History", + "user.settings.security.active": "Active", + "user.settings.security.inactive": "Inactive", "user_list.notFound": "No users found", "user_profile.send.dm": "Send Message", "user_profile.webrtc.call": "Start Video Call", diff --git a/webapp/package.json b/webapp/package.json index 216292f34..f0b1b8bb2 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -4,15 +4,15 @@ "version": "0.0.1", "private": true, "dependencies": { - "autolinker": "1.4.0", + "autolinker": "1.4.2", "bootstrap": "3.3.7", - "bootstrap-colorpicker": "2.3.6", - "chart.js": "2.4.0", + "bootstrap-colorpicker": "2.5.1", + "chart.js": "2.5.0", "compass-mixins": "0.12.10", "fastclick": "1.0.6", "flux": "3.1.2", "font-awesome": "4.7.0", - "highlight.js": "9.9.0", + "highlight.js": "9.10.0", "inobounce": "0.1.4", "intl": "1.2.5", "jasny-bootstrap": "3.1.3", @@ -20,45 +20,45 @@ "marked": "mattermost/marked#8f5902fff9bad793cd6c66e0c44002c9e79e1317", "match-at": "0.1.0", "object-assign": "4.1.1", - "pdfjs-dist": "1.7.235", + "pdfjs-dist": "1.7.363", "perfect-scrollbar": "0.6.16", "react": "15.4.2", "react-addons-pure-render-mixin": "15.4.2", - "react-bootstrap": "0.30.7", - "react-custom-scrollbars": "4.0.1", + "react-bootstrap": "0.30.8", + "react-custom-scrollbars": "4.0.2", "react-dom": "15.4.2", "react-intl": "2.2.3", "react-router": "2.8.1", - "react-select": "1.0.0-rc.2", - "superagent": "3.4.1", - "twemoji": "2.2.3", - "velocity-animate": "1.4.2", - "webrtc-adapter": "3.1.0", + "react-select": "1.0.0-rc.3", + "superagent": "3.5.0", + "twemoji": "2.2.5", + "velocity-animate": "1.4.3", + "webrtc-adapter": "3.2.0", "xregexp": "3.1.1" }, "devDependencies": { - "babel-core": "6.22.1", - "babel-eslint": "7.1.0", - "babel-loader": "6.2.10", - "babel-plugin-transform-runtime": "6.22.0", - "babel-polyfill": "6.22.0", - "babel-preset-es2015": "6.22.0", - "babel-preset-react": "6.22.0", + "babel-core": "6.24.0", + "babel-eslint": "7.1.1", + "babel-loader": "6.4.0", + "babel-plugin-transform-runtime": "6.23.0", + "babel-polyfill": "6.23.0", + "babel-preset-es2015": "6.24.0", + "babel-preset-react": "6.23.0", "babel-preset-stage-0": "6.22.0", "copy-webpack-plugin": "4.0.1", - "cross-env": "3.1.4", - "css-loader": "0.26.1", - "eslint": "3.10.2", - "eslint-plugin-react": "6.7.1", - "exports-loader": "0.6.3", - "extract-text-webpack-plugin": "1.0.1", - "file-loader": "0.10.0", - "html-loader": "0.4.4", + "cross-env": "3.2.3", + "css-loader": "0.27.3", + "eslint": "3.17.1", + "eslint-plugin-react": "6.10.0", + "exports-loader": "0.6.4", + "extract-text-webpack-plugin": "2.1.0", + "file-loader": "0.10.1", + "html-loader": "0.4.5", "html-webpack-plugin": "2.28.0", "image-webpack-loader": "3.2.0", - "imports-loader": "0.7.0", + "imports-loader": "0.7.1", "jquery-deferred": "0.3.1", - "jsdom": "9.9.1", + "jsdom": "9.12.0", "jsdom-global": "2.1.1", "json-loader": "0.5.4", "mocha": "3.2.0", @@ -67,9 +67,9 @@ "node-sass": "4.5.0", "raw-loader": "0.5.1", "react-addons-test-utils": "15.4.2", - "sass-loader": "4.1.1", - "style-loader": "0.13.1", - "url-loader": "0.5.7", + "sass-loader": "6.0.3", + "style-loader": "0.13.2", + "url-loader": "0.5.8", "webpack": "2.2.1", "webpack-node-externals": "1.5.4" }, diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss index 93bd9fda4..bfc082ad3 100644 --- a/webapp/sass/components/_modal.scss +++ b/webapp/sass/components/_modal.scss @@ -345,7 +345,7 @@ } img { - max-height: 100%; + max-height: calc(100vh - 200px); max-width: 100%; } diff --git a/webapp/sass/components/_popover.scss b/webapp/sass/components/_popover.scss index 6b1c57725..93b567ad3 100644 --- a/webapp/sass/components/_popover.scss +++ b/webapp/sass/components/_popover.scss @@ -209,6 +209,15 @@ .more-modal__row { min-height: inherit; } + + .more-modal__details { + line-height: 32px; + } + + .more-modal__actions { + line-height: 31px; + margin: 0; + } } .popover-content { diff --git a/webapp/sass/components/_tooltip.scss b/webapp/sass/components/_tooltip.scss index 0049fe1b8..6953dad58 100644 --- a/webapp/sass/components/_tooltip.scss +++ b/webapp/sass/components/_tooltip.scss @@ -7,6 +7,12 @@ padding: 3px 10px 4px; word-break: break-word; } + + &.text-nowrap { + .tooltip-inner { + white-space: nowrap; + } + } } #webrtcTooltip { diff --git a/webapp/sass/layout/_content.scss b/webapp/sass/layout/_content.scss index 02f063573..b6fe98eb4 100644 --- a/webapp/sass/layout/_content.scss +++ b/webapp/sass/layout/_content.scss @@ -9,10 +9,20 @@ .search-btns { display: none; } - .header-list__members { + .header-list__right { + // the negative margin-right is used + // to prevent the icons in the header from + // moving to the left when the RHS is open + // + // the below z-index is used to ensure the icons + // stays on the top and don't get hidden by the + // search's input block + position: relative; + z-index: 6; + margin-right: -18px; - float: right; padding-right: 0px !important; + float: right; } } @@ -23,10 +33,20 @@ .search-btns { display: none; } - .header-list__members { + .header-list__right { + // the negative margin-right is used + // to prevent the icons in the header from + // moving to the left when the RHS is open + // + // the below z-index is used to ensure the icons + // stays on the top and don't get hidden by the + // search's input block + position: relative; + z-index: 6; + margin-right: -18px; - float: right; padding-right: 0px !important; + float: right } } } diff --git a/webapp/sass/layout/_forms.scss b/webapp/sass/layout/_forms.scss index 7552290d8..64c74b0a5 100644 --- a/webapp/sass/layout/_forms.scss +++ b/webapp/sass/layout/_forms.scss @@ -62,7 +62,6 @@ .has-error { .help-block, - .control-label, .radio, .checkbox, .radio-inline, @@ -70,6 +69,10 @@ color: $red; } + .control-label { + color: inherit; + } + &.radio, &.checkbox, &.radio-inline, diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss index 8ee6e8fdc..f8211d433 100644 --- a/webapp/sass/layout/_headers.scss +++ b/webapp/sass/layout/_headers.scss @@ -7,26 +7,43 @@ line-height: 56px; width: 100%; - .member-popover__trigger { + .member-popover__trigger, + .pinned-posts-button { cursor: pointer; - min-width: 60px; - padding-right: 10px; - text-align: right; + display: inline-block; + margin-left: 7px; + min-width: 30px; + text-align: center; white-space: nowrap; .fa { font-size: 16px; + } + } + + .member-popover__container, + .member-popover__trigger { + display: inline; + } + + .member-popover__trigger { + .fa { margin-left: 4px; } } + .pinned-posts-button svg { + position: relative; + top: 2px; + } + &.alt { margin: 0; th { font-weight: normal !important; - &.header-list__members { + &.header-list__right { padding-right: 4px; } } @@ -48,7 +65,7 @@ } &:last-child { - padding-right: 8px; + padding-right: 6px; width: 8.9%; } } diff --git a/webapp/sass/layout/_post-right.scss b/webapp/sass/layout/_post-right.scss index 455ed7fff..9a0f658a2 100644 --- a/webapp/sass/layout/_post-right.scss +++ b/webapp/sass/layout/_post-right.scss @@ -53,6 +53,12 @@ border: none; } + .date-separator { + hr { + border-top: 1px solid #eee; + } + } + .post-create__container { width: 100%; @@ -147,7 +153,8 @@ @include flex(1 1 auto); overflow: auto; position: relative; - + padding-top: 10px; + .file-preview__container { margin-top: 5px; } diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss index 5ecd50468..1e1dd4b08 100644 --- a/webapp/sass/layout/_post.scss +++ b/webapp/sass/layout/_post.scss @@ -359,6 +359,9 @@ } .post-create__container { + label { + font-weight: normal; + } .custom-textarea { overflow: hidden; } @@ -763,6 +766,7 @@ line-height: 1.6em; margin: 0; white-space: pre-wrap; + word-break: break-word; } .post__header--info { @@ -800,7 +804,7 @@ .flag-icon__container { left: 36px; - margin-left: 5px; + margin-left: 7px; position: absolute; top: 8px; } @@ -1357,15 +1361,25 @@ } } -.bot-indicator { +.bot-indicator, +.post__pinned-badge { border-radius: 2px; font-family: inherit; font-size: 10px; font-weight: 600; - margin: 2px 10px 0 -4px; padding: 1px 4px; } +.bot-indicator { + margin: 2px 10px 0 -4px; +} + +.post__pinned-badge { + margin-left: 7px; + position: relative; + top: -1px; +} + .permalink-text { overflow: hidden; } diff --git a/webapp/sass/layout/_webhooks.scss b/webapp/sass/layout/_webhooks.scss index f3a8c6fd3..c36edb8a2 100644 --- a/webapp/sass/layout/_webhooks.scss +++ b/webapp/sass/layout/_webhooks.scss @@ -41,6 +41,7 @@ &.attachment--opengraph { max-width: 800px; } + .attachment__content { border-radius: 4px; border-style: solid; @@ -71,16 +72,18 @@ &.attachment__container--danger { border-left-color: #e40303; } + &.attachment__container--opengraph { display: table; - table-layout: fixed; - width: 100%; margin: 0; padding-bottom: 13px; + width: 100%; + div { margin: 0; } } + .sitename { color: #A3A3A3; } @@ -89,8 +92,8 @@ .attachment__body__wrap { &.attachment__body__wrap--opengraph { display: table-cell; - width: 100%; vertical-align: top; + width: 100%; } } @@ -104,6 +107,7 @@ &.attachment__body--no_thumb { width: 100%; } + &.attachment__body--opengraph { float: none; padding-right: 0; @@ -142,6 +146,7 @@ margin-top: 10px; max-height: 200px; max-width: 400px; + width: 100%; &.loading { height: 150px; @@ -164,16 +169,17 @@ &.has-link { color: #2f81b7; - text-overflow: ellipsis; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } &.attachment__title--opengraph { height: auto; word-wrap: break-word; + &.is-url { - word-break: break-all + word-break: break-all; } } } diff --git a/webapp/sass/responsive/_desktop.scss b/webapp/sass/responsive/_desktop.scss index 891431f20..f671104e1 100644 --- a/webapp/sass/responsive/_desktop.scss +++ b/webapp/sass/responsive/_desktop.scss @@ -76,6 +76,23 @@ } } } + + &.move--left { + .post { + &.post--root, + &.other--root { + .post__header { + padding-right: 70px; + } + } + + &.post--comment { + .post__header { + padding-right: 70px; + } + } + } + } } } diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss index 891b0ed48..4fbec082a 100644 --- a/webapp/sass/responsive/_mobile.scss +++ b/webapp/sass/responsive/_mobile.scss @@ -1,6 +1,10 @@ @charset 'UTF-8'; @media screen and (max-width: 768px) { + .table-responsive { + border: none; + } + .multi-select__container { .btn { display: block; @@ -253,6 +257,7 @@ } } } + blockquote { margin-top: 0; } @@ -274,6 +279,7 @@ .post__header { margin-bottom: 0; + padding-right: 70px; .col__reply { top: -3px; @@ -1342,7 +1348,7 @@ a { border-bottom: 1px solid; - line-height: 50px; + line-height: 45px; position: relative; text-align: center; } diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss index 06a725a31..3bafc38d4 100644 --- a/webapp/sass/responsive/_tablet.scss +++ b/webapp/sass/responsive/_tablet.scss @@ -127,6 +127,15 @@ top: auto; } } + + &.move--left, + &.webrtc--show, + &.move--right { + .header-list__right { + // hide it behind the RHS + z-index: -1; + } + } } .post { .attachment { @@ -182,6 +191,14 @@ } } } + + .sidebar--right__title { + display: inline-block; + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } .inner-wrap { @@ -213,6 +230,11 @@ } } + .post__pinned-badge { + margin-left: 0; + margin-right: 5px; + } + &:not(.post--thread) { padding: 5px .5em 0 77px; @@ -359,9 +381,16 @@ } .post__header { + float: left; + padding-top: 3px; + .col__reply { top: -21px; } + + .post__pinned-badge { + margin-right: 5px; + } } &:not(.post--compact) { @@ -381,6 +410,12 @@ } } } + + &.post--comment:not(.post--compact) { + .post__pinned-badge { + margin-left: 10px; + } + } } } } diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx index 6e312f67a..6f81619c2 100644 --- a/webapp/stores/post_store.jsx +++ b/webapp/stores/post_store.jsx @@ -16,6 +16,7 @@ const FOCUSED_POST_CHANGE = 'focused_post_change'; const EDIT_POST_EVENT = 'edit_post'; const POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; const SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; +const POST_PINNED_CHANGE_EVENT = 'post_pinned_change'; class PostStoreClass extends EventEmitter { constructor() { @@ -259,22 +260,42 @@ class PostStoreClass extends EventEmitter { this.postsInfo[id].postList = combinedPosts; } + focusedPostListHasPost(id) { + const focusedPostId = this.getFocusedPostId(); + if (focusedPostId == null) { + return false; + } + + const focusedPostList = makePostListNonNull(this.getAllPosts(focusedPostId)); + return focusedPostList.posts.hasOwnProperty(id); + } + storePost(post, isNewPost = false) { - const postList = makePostListNonNull(this.getAllPosts(post.channel_id)); + const ids = [ + post.channel_id + ]; - if (post.pending_post_id !== '') { - this.removePendingPost(post.channel_id, post.pending_post_id); + // update the post in the permalink view if it's there + if (!isNewPost && this.focusedPostListHasPost(post.id)) { + ids.push(this.getFocusedPostId()); } - post.pending_post_id = ''; + ids.forEach((id) => { + const postList = makePostListNonNull(this.getAllPosts(id)); + if (post.pending_post_id !== '') { + this.removePendingPost(post.channel_id, post.pending_post_id); + } - postList.posts[post.id] = post; - if (isNewPost && postList.order.indexOf(post.id) === -1) { - postList.order.unshift(post.id); - } + post.pending_post_id = ''; + + postList.posts[post.id] = post; + if (isNewPost && postList.order.indexOf(post.id) === -1) { + postList.order.unshift(post.id); + } - this.makePostsInfo(post.channel_id); - this.postsInfo[post.channel_id].postList = postList; + this.makePostsInfo(post.channel_id); + this.postsInfo[id].postList = postList; + }); } storeFocusedPost(postId, channelId, postList) { @@ -500,6 +521,18 @@ class PostStoreClass extends EventEmitter { this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); } + emitPostPinnedChange() { + this.emit(POST_PINNED_CHANGE_EVENT); + } + + addPostPinnedChangeListener(callback) { + this.on(POST_PINNED_CHANGE_EVENT, callback); + } + + removePostPinnedChangeListener(callback) { + this.removeListener(POST_PINNED_CHANGE_EVENT, callback); + } + getCurrentUsersLatestPost(channelId, rootId) { const userId = UserStore.getCurrentId(); @@ -686,6 +719,10 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { PostStore.storeSelectedPostId(action.postId); PostStore.emitSelectedPostChange(action.from_search, action.from_flagged_posts); break; + case ActionTypes.RECEIVED_POST_PINNED: + case ActionTypes.RECEIVED_POST_UNPINNED: + PostStore.emitPostPinnedChange(); + break; default: } }); diff --git a/webapp/stores/search_store.jsx b/webapp/stores/search_store.jsx index 46a086ddb..49f8b3c2f 100644 --- a/webapp/stores/search_store.jsx +++ b/webapp/stores/search_store.jsx @@ -19,6 +19,7 @@ class SearchStoreClass extends EventEmitter { this.searchResults = null; this.isMentionSearch = false; this.isFlaggedPosts = false; + this.isPinnedPosts = false; this.isVisible = false; this.searchTerm = ''; } @@ -83,6 +84,10 @@ class SearchStoreClass extends EventEmitter { return this.isFlaggedPosts; } + getIsPinnedPosts() { + return this.isPinnedPosts; + } + storeSearchTerm(term) { this.searchTerm = term; } @@ -91,10 +96,11 @@ class SearchStoreClass extends EventEmitter { return this.searchTerm; } - storeSearchResults(results, isMentionSearch, isFlaggedPosts) { + storeSearchResults(results, isMentionSearch, isFlaggedPosts, isPinnedPosts) { this.searchResults = results; this.isMentionSearch = isMentionSearch; this.isFlaggedPosts = isFlaggedPosts; + this.isPinnedPosts = isPinnedPosts; } deletePost(post) { @@ -120,7 +126,7 @@ SearchStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECEIVED_SEARCH: - SearchStore.storeSearchResults(action.results, action.is_mention_search, action.is_flagged_posts); + SearchStore.storeSearchResults(action.results, action.is_mention_search, action.is_flagged_posts, action.is_pinned_posts); SearchStore.emitSearchChange(); break; case ActionTypes.RECEIVED_SEARCH_TERM: diff --git a/webapp/tests/formatting_imgs.test.jsx b/webapp/tests/formatting_imgs.test.jsx new file mode 100644 index 000000000..fac9a755f --- /dev/null +++ b/webapp/tests/formatting_imgs.test.jsx @@ -0,0 +1,55 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Markdown from 'utils/markdown.jsx'; + +describe('Markdown.Imgs', function() { + this.timeout(10000); + + it('Inline mage', function(done) { + assert.equal( + Markdown.format('![Mattermost](/images/icon.png)').trim(), + '<p><img src="/images/icon.png" alt="Mattermost" onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img" crossorigin="anonymous"></p>' + ); + + done(); + }); + + it('Image with hover text', function(done) { + assert.equal( + Markdown.format('![Mattermost](/images/icon.png "Mattermost Icon")').trim(), + '<p><img src="/images/icon.png" alt="Mattermost" title="Mattermost Icon" onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img" crossorigin="anonymous"></p>' + ); + + done(); + }); + + it('Image with link', function(done) { + assert.equal( + Markdown.format('[![Mattermost](../../images/icon-76x76.png)](https://github.com/mattermost/platform)').trim(), + '<p><a class="theme markdown__link" href="https://github.com/mattermost/platform" rel="noreferrer" target="_blank"><img src="../../images/icon-76x76.png" alt="Mattermost" onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img" crossorigin="anonymous"></a></p>' + ); + + done(); + }); + + it('Image with width and height', function(done) { + assert.equal( + Markdown.format('![Mattermost](../../images/icon-76x76.png =50x76 "Mattermost Icon")').trim(), + '<p><img src="../../images/icon-76x76.png" alt="Mattermost" title="Mattermost Icon" width="50" height="76" onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img" crossorigin="anonymous"></p>' + ); + + done(); + }); + + it('Image with width', function(done) { + assert.equal( + Markdown.format('![Mattermost](../../images/icon-76x76.png =50 "Mattermost Icon")').trim(), + '<p><img src="../../images/icon-76x76.png" alt="Mattermost" title="Mattermost Icon" width="50" onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img" crossorigin="anonymous"></p>' + ); + + done(); + }); +}); diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index 9ba853238..1fc19b5f2 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -137,7 +137,7 @@ export function getMyChannelMembers() { (err) => { callTracker.getMyChannelMembers = 0; dispatchError(err, 'getMyChannelMembers'); - reject(); + reject(new Error('Unable to getMyChannelMembers')); } ); }); @@ -166,7 +166,7 @@ export function getMyChannelMembersForTeam(teamId) { (err) => { callTracker[`getMyChannelMembers${teamId}`] = 0; dispatchError(err, 'getMyChannelMembersForTeam'); - reject(); + reject(new Error('Unable to getMyChannelMembersForTeam')); } ); }); @@ -308,7 +308,7 @@ export function getChannelMember(channelId, userId) { (err) => { callTracker[`getChannelMember${channelId}${userId}`] = 0; dispatchError(err, 'getChannelMember'); - reject(); + reject(new Error('Unable to getChannelMeber')); } ); }); @@ -1612,6 +1612,40 @@ export function deleteEmoji(id) { ); } +export function pinPost(channelId, reaction) { + Client.pinPost( + channelId, + reaction, + () => { + // the "post_edited" websocket event take cares of updating the posts + // the action below is mostly dispatched for the RHS to update + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_PINNED + }); + }, + (err) => { + dispatchError(err, 'pinPost'); + } + ); +} + +export function unpinPost(channelId, reaction) { + Client.unpinPost( + channelId, + reaction, + () => { + // the "post_edited" websocket event take cares of updating the posts + // the action below is mostly dispatched for the RHS to update + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_UNPINNED + }); + }, + (err) => { + dispatchError(err, 'unpinPost'); + } + ); +} + export function saveReaction(channelId, reaction) { Client.saveReaction( channelId, diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 541fb48ec..d8fc169a3 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -90,6 +90,8 @@ export const ActionTypes = keyMirror({ RECEIVED_POST_SELECTED: null, RECEIVED_MENTION_DATA: null, RECEIVED_ADD_MENTION: null, + RECEIVED_POST_PINNED: null, + RECEIVED_POST_UNPINNED: null, RECEIVED_PROFILES: null, RECEIVED_PROFILES_IN_TEAM: null, @@ -419,6 +421,7 @@ export const Constants = { REPLY_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'viewBox='-158 242 18 18' style='enable-background:new -158 242 18 18;' xml:space='preserve'> <path d='M-142.2,252.6c-2-3-4.8-4.7-8.3-4.8v-3.3c0-0.2-0.1-0.3-0.2-0.3s-0.3,0-0.4,0.1l-6.9,6.2c-0.1,0.1-0.1,0.2-0.1,0.3 c0,0.1,0,0.2,0.1,0.3l6.9,6.4c0.1,0.1,0.3,0.1,0.4,0.1c0.1-0.1,0.2-0.2,0.2-0.4v-3.8c4.2,0,7.4,0.4,9.6,4.4c0.1,0.1,0.2,0.2,0.3,0.2 c0,0,0.1,0,0.1,0c0.2-0.1,0.3-0.3,0.2-0.4C-140.2,257.3-140.6,255-142.2,252.6z M-150.8,252.5c-0.2,0-0.4,0.2-0.4,0.4v3.3l-6-5.5 l6-5.3v2.8c0,0.2,0.2,0.4,0.4,0.4c3.3,0,6,1.5,8,4.5c0.5,0.8,0.9,1.6,1.2,2.3C-144,252.8-147.1,252.5-150.8,252.5z'/> </svg>", SCROLL_BOTTOM_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'viewBox='-239 239 21 23' style='enable-background:new -239 239 21 23;' xml:space='preserve'> <path d='M-239,241.4l2.4-2.4l8.1,8.2l8.1-8.2l2.4,2.4l-10.5,10.6L-239,241.4z M-228.5,257.2l8.1-8.2l2.4,2.4l-10.5,10.6l-10.5-10.6 l2.4-2.4L-228.5,257.2z'/> </svg>", VIDEO_ICON: "<svg width='55%'height='100%'viewBox='0 0 13 8'> <g transform='matrix(1,0,0,1,-507,-146)'> <g transform='matrix(0.0133892,0,0,0.014499,500.635,142.838)'> <path d='M1158,547.286L1158,644.276C1158,684.245 1125.55,716.694 1085.58,716.694L579.341,716.694C539.372,716.694 506.922,684.245 506.922,644.276L506.922,306.322C506.922,266.353 539.371,233.904 579.341,233.903L1085.58,233.903C1125.55,233.904 1158,266.353 1158,306.322L1158,402.939L1359.75,253.14C1365.83,248.362 1373.43,245.973 1382.56,245.973C1386.61,245.973 1390.83,246.602 1395.22,247.859C1408.4,252.134 1414.99,259.552 1414.99,270.113L1414.99,680.485C1414.99,691.046 1408.4,698.464 1395.22,702.739C1390.83,703.996 1386.61,704.624 1382.56,704.624C1373.43,704.624 1365.83,702.236 1359.75,697.458L1158,547.286Z'/> </g> </g> </svg>", + PIN_ICON: "<svg width='16px' height='16px' viewBox='0 0 25 25' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' clip-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='1.414'><path d='M24.78 9.236L15.863.316l-1.487 4.46-4.46 4.46L8.43 7.75 3.972 9.235l4.458 4.458L.776 24.388l10.627-7.72 4.46 4.46 1.485-4.46-1.486-1.485 4.46-4.46 4.46-1.487z' fill-rule='nonzero'/></svg>", THEMES: { default: { type: 'Organization', @@ -865,6 +868,8 @@ export const Constants = { DEFAULT_MAX_NOTIFICATIONS_PER_CHANNEL: 1000, MAX_TEAMNAME_LENGTH: 15, MAX_TEAMDESCRIPTION_LENGTH: 50, + MIN_CHANNELNAME_LENGTH: 2, + MAX_CHANNELNAME_LENGTH: 22, MIN_USERNAME_LENGTH: 3, MAX_USERNAME_LENGTH: 22, MAX_NICKNAME_LENGTH: 22, diff --git a/webapp/utils/markdown.jsx b/webapp/utils/markdown.jsx index c84df0fa5..fa9c985c7 100644 --- a/webapp/utils/markdown.jsx +++ b/webapp/utils/markdown.jsx @@ -152,6 +152,7 @@ class MattermostMarkdownRenderer extends marked.Renderer { out += ' height="' + dimensions[1] + '"'; } out += ' onload="window.markdownImageLoaded(this)" onerror="window.markdownImageLoaded(this)" class="markdown-inline-img"'; + out += ' crossorigin="anonymous"'; out += this.options.xhtml ? '/>' : '>'; return out; } diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index c860987af..b3370e88c 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -32,7 +32,7 @@ export function isMac() { } export function cmdOrCtrlPressed(e) { - return (isMac() && e.metaKey) || (!isMac() && e.ctrlKey); + return (isMac() && e.metaKey) || (!isMac() && e.ctrlKey && !e.altKey); } export function isInRole(roles, inRole) { @@ -179,7 +179,7 @@ export function displayTime(ticks, utc) { ampm = ' PM'; } - hours = hours % 12; + hours %= 12; if (!hours) { hours = '12'; } @@ -591,6 +591,7 @@ export function applyTheme(theme) { changeCss('.app__body .markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07)); changeCss('.app__body .channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8)); changeCss('.app__body .channel-header #member_popover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8)); + changeCss('.app__body .channel-header #pinned-posts-button', 'fill:' + changeOpacity(theme.centerChannelColor, 0.8)); changeCss('.app__body .custom-textarea, .app__body .custom-textarea:focus, .app__body .file-preview, .app__body .post-image__details, .app__body .sidebar--right .sidebar-right__body, .app__body .markdown__table th, .app__body .markdown__table td, .app__body .suggestion-list__content, .app__body .modal .modal-content, .app__body .modal .settings-modal .settings-table .settings-content .divider-light, .app__body .webhooks__container, .app__body .dropdown-menu, .app__body .modal .modal-header, .app__body .popover', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2)); changeCss('.app__body .popover.bottom>.arrow', 'border-bottom-color:' + changeOpacity(theme.centerChannelColor, 0.25)); changeCss('.app__body .search-help-popover .search-autocomplete__divider span, .app__body .suggestion-list__divider > span', 'color:' + changeOpacity(theme.centerChannelColor, 0.7)); @@ -659,12 +660,12 @@ export function applyTheme(theme) { } if (theme.buttonBg) { - changeCss('.app__body .btn.btn-primary, .app__body .tutorial__circles .circle.active', 'background:' + theme.buttonBg); + changeCss('.app__body .btn.btn-primary, .app__body .tutorial__circles .circle.active, .app__body .post__pinned-badge', 'background:' + theme.buttonBg); changeCss('.app__body .btn.btn-primary:hover, .app__body .btn.btn-primary:active, .app__body .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.25)); } if (theme.buttonColor) { - changeCss('.app__body .btn.btn-primary', 'color:' + theme.buttonColor); + changeCss('.app__body .btn.btn-primary, .app__body .post__pinned-badge', 'color:' + theme.buttonColor); } if (theme.mentionHighlightBg) { @@ -1216,7 +1217,7 @@ export function isValidPassword(password) { error = true; } - errorId = errorId + 'Lowercase'; + errorId += 'Lowercase'; } if (global.window.mm_config.PasswordRequireUppercase === 'true') { @@ -1224,7 +1225,7 @@ export function isValidPassword(password) { error = true; } - errorId = errorId + 'Uppercase'; + errorId += 'Uppercase'; } if (global.window.mm_config.PasswordRequireNumber === 'true') { @@ -1232,7 +1233,7 @@ export function isValidPassword(password) { error = true; } - errorId = errorId + 'Number'; + errorId += 'Number'; } if (global.window.mm_config.PasswordRequireSymbol === 'true') { @@ -1240,7 +1241,7 @@ export function isValidPassword(password) { error = true; } - errorId = errorId + 'Symbol'; + errorId += 'Symbol'; } minimumLength = global.window.mm_config.PasswordMinimumLength; diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js index f1742e3ae..32c5a322a 100644 --- a/webapp/webpack.config.js +++ b/webapp/webpack.config.js @@ -65,7 +65,16 @@ var config = { }, { test: /\.scss$/, - loaders: ['style-loader', 'css-loader', 'sass-loader'] + use: [{ + loader: 'style-loader' + }, { + loader: 'css-loader' + }, { + loader: 'sass-loader', + options: { + includePaths: ['node_modules/compass-mixins/lib'] + } + }] }, { test: /\.css$/, @@ -92,13 +101,6 @@ var config = { minimize: !DEV, debug: false }), - new webpack.LoaderOptionsPlugin({ - options: { - sassLoader: { - includePaths: ['node_modules/compass-mixins/lib'] - } - } - }), new webpack.optimize.CommonsChunkPlugin({ minChunks: 2, children: true |