diff options
Diffstat (limited to 'web/react')
25 files changed, 1120 insertions, 450 deletions
diff --git a/web/react/.eslintrc b/web/react/.eslintrc index 53cc75913..c0d0bb200 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -18,7 +18,8 @@ "es6": true }, "globals": { - "React": false + "React": false, + "ReactBootstrap": false }, "rules": { "comma-dangle": [2, "never"], diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index bb43af802..68984c9e0 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -4,6 +4,7 @@ var AdminSidebar = require('./admin_sidebar.jsx'); var EmailTab = require('./email_settings.jsx'); var JobsTab = require('./jobs_settings.jsx'); +var LogsTab = require('./logs.jsx'); var Navbar = require('../../components/navbar.jsx'); export default class AdminController extends React.Component { @@ -28,6 +29,8 @@ export default class AdminController extends React.Component { tab = <EmailTab />; } else if (this.state.selected === 'job_settings') { tab = <JobsTab />; + } else if (this.state.selected === 'logs') { + tab = <LogsTab />; } return ( diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 6b3be89d0..a04bceef5 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -83,7 +83,15 @@ export default class AdminSidebar extends React.Component { {'Email Settings'} </a> </li> - <li><a href='#'>{'Other Settings'}</a></li> + <li> + <a + href='#' + className={this.isSelected('logs')} + onClick={this.handleClick.bind(null, 'logs')} + > + {'Logs'} + </a> + </li> </ul> </li> <li> diff --git a/web/react/components/admin_console/logs.jsx b/web/react/components/admin_console/logs.jsx new file mode 100644 index 000000000..d7de76a94 --- /dev/null +++ b/web/react/components/admin_console/logs.jsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var AdminStore = require('../../stores/admin_store.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); + +export default class Logs extends React.Component { + constructor(props) { + super(props); + + this.onLogListenerChange = this.onLogListenerChange.bind(this); + this.reload = this.reload.bind(this); + + this.state = { + logs: AdminStore.getLogs() + }; + } + + componentDidMount() { + AdminStore.addLogChangeListener(this.onLogListenerChange); + AsyncClient.getLogs(); + } + componentWillUnmount() { + AdminStore.removeLogChangeListener(this.onLogListenerChange); + } + onLogListenerChange() { + this.setState({ + logs: AdminStore.getLogs() + }); + } + + reload() { + AdminStore.saveLogs(null); + this.setState({ + logs: null + }); + + AsyncClient.getLogs(); + } + + render() { + var content = null; + + if (this.state.logs === null) { + content = <LoadingScreen />; + } else { + content = []; + + for (var i = 0; i < this.state.logs.length; i++) { + var style = { + whiteSpace: 'nowrap', + fontFamily: 'monospace' + }; + + if (this.state.logs[i].indexOf('[EROR]') > 0) { + style.color = 'red'; + } + + content.push(<br key={'br_' + i} />); + content.push( + <span + key={'log_' + i} + style={style} + > + {this.state.logs[i]} + </span> + ); + } + } + + return ( + <div className='panel'> + <h3>{'Server Logs'}</h3> + <button + type='submit' + className='btn btn-primary' + onClick={this.reload} + > + {'Reload'} + </button> + <div className='log__panel'> + {content} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/web/react/components/change_url_modal.jsx b/web/react/components/change_url_modal.jsx new file mode 100644 index 000000000..28fa70c1f --- /dev/null +++ b/web/react/components/change_url_modal.jsx @@ -0,0 +1,177 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Modal = ReactBootstrap.Modal; +var Utils = require('../utils/utils.jsx'); + +export default class ChangeUrlModal extends React.Component { + constructor(props) { + super(props); + + this.onURLChanged = this.onURLChanged.bind(this); + this.doSubmit = this.doSubmit.bind(this); + this.doCancel = this.doCancel.bind(this); + + this.state = { + currentURL: props.currentURL, + urlError: '', + userEdit: false + }; + } + componentWillReceiveProps(nextProps) { + // This check prevents the url being deleted when we re-render + // because of user status check + if (!this.state.userEdit) { + this.setState({ + currentURL: nextProps.currentURL + }); + } + } + componentDidUpdate(prevProps) { + if (this.props.show === true && prevProps.show === false) { + React.findDOMNode(this.refs.urlinput).select(); + } + } + onURLChanged(e) { + const url = e.target.value.trim(); + this.setState({currentURL: url.replace(/[^A-Za-z0-9-_]/g, '').toLowerCase(), userEdit: true}); + } + getURLError(url) { + let error = []; //eslint-disable-line prefer-const + if (url.length < 2) { + error.push(<span key='error1'>{'Must be longer than two characters'}<br/></span>); + } + if (url.charAt(0) === '-' || url.charAt(0) === '_') { + error.push(<span key='error2'>{'Must start with a letter or number'}<br/></span>); + } + if (url.length > 1 && (url.charAt(url.length - 1) === '-' || url.charAt(url.length - 1) === '_')) { + error.push(<span key='error3'>{'Must end with a letter or number'}<br/></span>); + } + if (url.indexOf('__') > -1) { + error.push(<span key='error4'>{'Can not contain two underscores in a row.'}<br/></span>); + } + + // In case of error we don't detect + if (error.length === 0) { + error.push(<span key='errorlast'>{'Invalid URL'}<br/></span>); + } + return error; + } + doSubmit(e) { + e.preventDefault(); + + const url = React.findDOMNode(this.refs.urlinput).value; + const cleanedURL = Utils.cleanUpUrlable(url); + if (cleanedURL !== url || url.length < 2 || url.indexOf('__') > -1) { + this.setState({urlError: this.getURLError(url)}); + return; + } + this.setState({urlError: '', userEdit: false}); + this.props.onModalSubmit(url); + } + doCancel() { + this.setState({urlError: '', userEdit: false}); + this.props.onModalDismissed(); + } + render() { + let urlClass = 'input-group input-group--limit'; + let urlError = null; + let serverError = null; + + if (this.state.urlError) { + urlClass += ' has-error'; + urlError = (<p className='input__help error'>{this.state.urlError}</p>); + } + + if (this.props.serverError) { + serverError = <div className='form-group has-error'><p className='input__help error'>{this.props.serverError}</p></div>; + } + + const fullTeamUrl = Utils.getTeamURLFromAddressBar(); + const teamURL = Utils.getShortenedTeamURL(); + + return ( + <Modal + show={this.props.show} + onHide={this.doCancel} + > + <Modal.Header closeButton={true}> + <Modal.Title>{this.props.title}</Modal.Title> + </Modal.Header> + <form + role='form' + className='form-horizontal' + > + <Modal.Body> + <div className='modal-intro'>{this.props.description}</div> + <div className='form-group'> + <label className='col-sm-2 form__label control-label'>{this.props.urlLabel}</label> + <div className='col-sm-10'> + <div className={urlClass}> + <span + data-toggle='tooltip' + title={fullTeamUrl} + className='input-group-addon' + > + {teamURL} + </span> + <input + type='text' + ref='urlinput' + className='form-control' + maxLength='22' + onChange={this.onURLChanged} + value={this.state.currentURL} + autoFocus={true} + tabIndex='1' + /> + </div> + {urlError} + {serverError} + </div> + </div> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.doCancel} + > + {'Close'} + </button> + <button + onClick={this.doSubmit} + type='submit' + className='btn btn-primary' + tabIndex='2' + > + {this.props.submitButtonText} + </button> + </Modal.Footer> + </form> + </Modal> + ); + } +} + +ChangeUrlModal.defaultProps = { + show: false, + title: 'Change URL', + desciption: '', + urlLabel: 'URL', + submitButtonText: 'Submit', + currentURL: '', + serverError: '' +}; + +ChangeUrlModal.propTypes = { + show: React.PropTypes.bool.isRequired, + title: React.PropTypes.string, + description: React.PropTypes.string, + urlLabel: React.PropTypes.string, + submitButtonText: React.PropTypes.string, + currentURL: React.PropTypes.string, + serverError: React.PropTypes.string, + onModalSubmit: React.PropTypes.func.isRequired, + onModalDismissed: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index db23a5831..0dbbc20d4 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -8,6 +8,7 @@ const SocketStore = require('../stores/socket_store.jsx'); const NavbarSearchBox = require('./search_bar.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); +const TextFormatting = require('../utils/text_formatting.jsx'); const Utils = require('../utils/utils.jsx'); const MessageWrapper = require('./message_wrapper.jsx'); const PopoverListMembers = require('./popover_list_members.jsx'); @@ -107,7 +108,6 @@ export default class ChannelHeader extends React.Component { } const channel = this.state.channel; - const description = Utils.textToJsx(channel.description, {singleline: true, noMentionHighlight: true}); const popoverContent = React.renderToString(<MessageWrapper message={channel.description}/>); let channelTitle = channel.display_name; const currentId = UserStore.getCurrentId(); @@ -326,9 +326,9 @@ export default class ChannelHeader extends React.Component { data-toggle='popover' data-content={popoverContent} className='description' - > - {description} - </div> + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.description, {singleline: true, mentionHighlight: false})}} + /> </div> </th> <th> diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx index bd8945878..3cb284171 100644 --- a/web/react/components/file_upload.jsx +++ b/web/react/components/file_upload.jsx @@ -52,7 +52,7 @@ export default class FileUpload extends React.Component { } // generate a unique id that can be used by other components to refer back to this upload - var clientId = utils.generateId(); + let clientId = utils.generateId(); // prepare data to be uploaded var formData = new FormData(); diff --git a/web/react/components/message_wrapper.jsx b/web/react/components/message_wrapper.jsx index bce305853..5adf4f228 100644 --- a/web/react/components/message_wrapper.jsx +++ b/web/react/components/message_wrapper.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); +var TextFormatting = require('../utils/text_formatting.jsx'); export default class MessageWrapper extends React.Component { constructor(props) { @@ -10,10 +10,7 @@ export default class MessageWrapper extends React.Component { } render() { if (this.props.message) { - var inner = Utils.textToJsx(this.props.message, this.props.options); - return ( - <div>{inner}</div> - ); + return <div dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.message, this.props.options)}}/>; } return <div/>; @@ -21,8 +18,7 @@ export default class MessageWrapper extends React.Component { } MessageWrapper.defaultProps = { - message: null, - options: null + message: '' }; MessageWrapper.propTypes = { message: React.PropTypes.string, diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index 2258bf2b3..cae9f12e4 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -332,7 +332,7 @@ export default class Navbar extends React.Component { popoverContent = React.renderToString( <MessageWrapper message={channel.description} - options={{singleline: true, noMentionHighlight: true}} + options={{singleline: true, mentionHighlight: false}} /> ); isAdmin = this.state.member.roles.indexOf('admin') > -1; diff --git a/web/react/components/new_channel.jsx b/web/react/components/new_channel.jsx deleted file mode 100644 index 1a11fc652..000000000 --- a/web/react/components/new_channel.jsx +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var asyncClient = require('../utils/async_client.jsx'); -var UserStore = require('../stores/user_store.jsx'); - -export default class NewChannelModal extends React.Component { - constructor() { - super(); - - this.handleSubmit = this.handleSubmit.bind(this); - this.displayNameKeyUp = this.displayNameKeyUp.bind(this); - this.handleClose = this.handleClose.bind(this); - - this.state = {channelType: ''}; - } - handleSubmit(e) { - e.preventDefault(); - - var channel = {}; - var state = {serverError: ''}; - - channel.display_name = React.findDOMNode(this.refs.display_name).value.trim(); - if (!channel.display_name) { - state.displayNameError = 'This field is required'; - state.inValid = true; - } else if (channel.display_name.length > 22) { - state.displayNameError = 'This field must be less than 22 characters'; - state.inValid = true; - } else { - state.displayNameError = ''; - } - - channel.name = React.findDOMNode(this.refs.channel_name).value.trim(); - if (!channel.name) { - state.nameError = 'This field is required'; - state.inValid = true; - } else if (channel.name.length > 22) { - state.nameError = 'This field must be less than 22 characters'; - state.inValid = true; - } else { - var cleanedName = utils.cleanUpUrlable(channel.name); - if (cleanedName !== channel.name) { - state.nameError = "Must be lowercase alphanumeric characters, allowing '-' but not starting or ending with '-'"; - state.inValid = true; - } else { - state.nameError = ''; - } - } - - this.setState(state); - - if (state.inValid) { - return; - } - - var cu = UserStore.getCurrentUser(); - channel.team_id = cu.team_id; - - channel.description = React.findDOMNode(this.refs.channel_desc).value.trim(); - channel.type = this.state.channelType; - - client.createChannel(channel, - function success(data) { - $(React.findDOMNode(this.refs.modal)).modal('hide'); - - asyncClient.getChannel(data.id); - utils.switchChannel(data); - - React.findDOMNode(this.refs.display_name).value = ''; - React.findDOMNode(this.refs.channel_name).value = ''; - React.findDOMNode(this.refs.channel_desc).value = ''; - }.bind(this), - function error(err) { - state.serverError = err.message; - state.inValid = true; - this.setState(state); - }.bind(this) - ); - } - displayNameKeyUp() { - var displayName = React.findDOMNode(this.refs.display_name).value.trim(); - var channelName = utils.cleanUpUrlable(displayName); - React.findDOMNode(this.refs.channel_name).value = channelName; - } - componentDidMount() { - var self = this; - $(React.findDOMNode(this.refs.modal)).on('show.bs.modal', function onModalShow(e) { - var button = e.relatedTarget; - self.setState({channelType: $(button).attr('data-channeltype')}); - }); - $(React.findDOMNode(this.refs.modal)).on('hidden.bs.modal', this.handleClose); - } - componentWillUnmount() { - $(React.findDOMNode(this.refs.modal)).off('hidden.bs.modal', this.handleClose); - } - handleClose() { - $(React.findDOMNode(this)).find('.form-control').each(function clearForms() { - this.value = ''; - }); - - this.setState({channelType: '', displayNameError: '', nameError: '', serverError: '', inValid: false}); - } - render() { - var displayNameError = null; - var nameError = null; - var serverError = null; - var displayNameClass = 'form-group'; - var nameClass = 'form-group'; - - if (this.state.displayNameError) { - displayNameError = <label className='control-label'>{this.state.displayNameError}</label>; - displayNameClass += ' has-error'; - } - if (this.state.nameError) { - nameError = <label className='control-label'>{this.state.nameError}</label>; - nameClass += ' has-error'; - } - if (this.state.serverError) { - serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; - } - - var channelTerm = 'Channel'; - if (this.state.channelType === 'P') { - channelTerm = 'Group'; - } - - return ( - <div - className='modal fade' - id='new_channel' - ref='modal' - tabIndex='-1' - role='dialog' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - > - <span aria-hidden='true'>×</span> - <span className='sr-only'>Cancel</span> - </button> - <h4 className='modal-title'>New {channelTerm}</h4> - </div> - <form role='form'> - <div className='modal-body'> - <div className={displayNameClass}> - <label className='control-label'>Display Name</label> - <input - onKeyUp={this.displayNameKeyUp} - type='text' - ref='display_name' - className='form-control' - placeholder='Enter display name' - maxLength='22' - /> - {displayNameError} - </div> - <div className={nameClass}> - <label className='control-label'>Handle</label> - <input - type='text' - className='form-control' - ref='channel_name' - placeholder="lowercase alphanumeric's only" - maxLength='22' - /> - {nameError} - </div> - <div className='form-group'> - <label className='control-label'>Description</label> - <textarea - className='form-control no-resize' - ref='channel_desc' - rows='3' - placeholder='Description' - maxLength='1024' - /> - </div> - {serverError} - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - Cancel - </button> - <button - onClick={this.handleSubmit} - type='submit' - className='btn btn-primary' - > - Create New {channelTerm} - </button> - </div> - </form> - </div> - </div> - </div> - ); - } -} diff --git a/web/react/components/new_channel_flow.jsx b/web/react/components/new_channel_flow.jsx new file mode 100644 index 000000000..df6a119d5 --- /dev/null +++ b/web/react/components/new_channel_flow.jsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Utils = require('../utils/utils.jsx'); +var AsyncClient = require('../utils/async_client.jsx'); +var Client = require('../utils/client.jsx'); +var UserStore = require('../stores/user_store.jsx'); + +var NewChannelModal = require('./new_channel_modal.jsx'); +var ChangeURLModal = require('./change_url_modal.jsx'); + +const SHOW_NEW_CHANNEL = 1; +const SHOW_EDIT_URL = 2; +const SHOW_EDIT_URL_THEN_COMPLETE = 3; + +export default class NewChannelFlow extends React.Component { + constructor(props) { + super(props); + + this.doSubmit = this.doSubmit.bind(this); + this.typeSwitched = this.typeSwitched.bind(this); + this.urlChangeRequested = this.urlChangeRequested.bind(this); + this.urlChangeSubmitted = this.urlChangeSubmitted.bind(this); + this.urlChangeDismissed = this.urlChangeDismissed.bind(this); + this.channelDataChanged = this.channelDataChanged.bind(this); + + this.state = { + serverError: '', + channelType: 'O', + flowState: SHOW_NEW_CHANNEL, + channelDisplayName: '', + channelName: '', + channelDescription: '', + nameModified: false + }; + } + componentWillReceiveProps(nextProps) { + // If we are being shown, grab channel type from props and clear + if (nextProps.show === true && this.props.show === false) { + this.setState({ + serverError: '', + channelType: nextProps.channelType, + flowState: SHOW_NEW_CHANNEL, + channelDisplayName: '', + channelName: '', + channelDescription: '', + nameModified: false + }); + } + } + doSubmit() { + var channel = {}; + + channel.display_name = this.state.channelDisplayName; + if (!channel.display_name) { + this.setState({serverError: 'Invalid Channel Name'}); + return; + } + + channel.name = this.state.channelName; + if (channel.name.length < 2) { + this.setState({flowState: SHOW_EDIT_URL_THEN_COMPLETE}); + return; + } + + const cu = UserStore.getCurrentUser(); + channel.team_id = cu.team_id; + channel.description = this.state.channelDescription; + channel.type = this.state.channelType; + + Client.createChannel(channel, + (data) => { + this.props.onModalDismissed(); + AsyncClient.getChannel(data.id); + Utils.switchChannel(data); + }, + (err) => { + if (err.message === 'Name must be 2 or more lowercase alphanumeric characters') { + this.setState({flowState: SHOW_EDIT_URL_THEN_COMPLETE}); + } + if (err.message === 'A channel with that handle already exists') { + this.setState({serverError: 'A channel with that URL already exists'}); + return; + } + this.setState({serverError: err.message}); + } + ); + } + typeSwitched() { + if (this.state.channelType === 'P') { + this.setState({channelType: 'O'}); + } else { + this.setState({channelType: 'P'}); + } + } + urlChangeRequested() { + this.setState({flowState: SHOW_EDIT_URL}); + } + urlChangeSubmitted(newURL) { + if (this.state.flowState === SHOW_EDIT_URL_THEN_COMPLETE) { + this.setState({channelName: newURL, nameModified: true}, this.doSubmit); + } else { + this.setState({flowState: SHOW_NEW_CHANNEL, serverError: '', channelName: newURL, nameModified: true}); + } + } + urlChangeDismissed() { + this.setState({flowState: SHOW_NEW_CHANNEL}); + } + channelDataChanged(data) { + this.setState({ + channelDisplayName: data.displayName, + channelDescription: data.description + }); + if (!this.state.nameModified) { + this.setState({channelName: Utils.cleanUpUrlable(data.displayName.trim())}); + } + } + render() { + const channelData = { + name: this.state.channelName, + displayName: this.state.channelDisplayName, + description: this.state.channelDescription + }; + + let showChannelModal = false; + let showGroupModal = false; + let showChangeURLModal = false; + + let changeURLTitle = ''; + let changeURLSubmitButtonText = ''; + let channelTerm = ''; + + // Only listen to flow state if we are being shown + if (this.props.show) { + switch (this.state.flowState) { + case SHOW_NEW_CHANNEL: + if (this.state.channelType === 'O') { + showChannelModal = true; + channelTerm = 'Channel'; + } else { + showGroupModal = true; + channelTerm = 'Group'; + } + break; + case SHOW_EDIT_URL: + showChangeURLModal = true; + changeURLTitle = 'Change ' + channelTerm + ' URL'; + changeURLSubmitButtonText = 'Change ' + channelTerm + ' URL'; + break; + case SHOW_EDIT_URL_THEN_COMPLETE: + showChangeURLModal = true; + changeURLTitle = 'Set ' + channelTerm + ' URL'; + changeURLSubmitButtonText = 'Create ' + channelTerm; + break; + } + } + return ( + <span> + <NewChannelModal + show={showChannelModal} + channelType={'O'} + channelData={channelData} + serverError={this.state.serverError} + onSubmitChannel={this.doSubmit} + onModalDismissed={this.props.onModalDismissed} + onTypeSwitched={this.typeSwitched} + onChangeURLPressed={this.urlChangeRequested} + onDataChanged={this.channelDataChanged} + /> + <NewChannelModal + show={showGroupModal} + channelType={'P'} + channelData={channelData} + serverError={this.state.serverError} + onSubmitChannel={this.doSubmit} + onModalDismissed={this.props.onModalDismissed} + onTypeSwitched={this.typeSwitched} + onChangeURLPressed={this.urlChangeRequested} + onDataChanged={this.channelDataChanged} + /> + <ChangeURLModal + show={showChangeURLModal} + title={changeURLTitle} + description={'Some characters are not allowed in URLs and may be removed.'} + urlLabel={channelTerm + ' URL'} + submitButtonText={changeURLSubmitButtonText} + currentURL={this.state.channelName} + serverError={this.state.serverError} + onModalSubmit={this.urlChangeSubmitted} + onModalDismissed={this.urlChangeDismissed} + /> + </span> + ); + } +} + +NewChannelFlow.defaultProps = { + show: false, + channelType: 'O' +}; + +NewChannelFlow.propTypes = { + show: React.PropTypes.bool.isRequired, + channelType: React.PropTypes.string.isRequired, + onModalDismissed: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx new file mode 100644 index 000000000..f3fb8da2a --- /dev/null +++ b/web/react/components/new_channel_modal.jsx @@ -0,0 +1,198 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +const Utils = require('../utils/utils.jsx'); +var Modal = ReactBootstrap.Modal; + +export default class NewChannelModal extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleChange = this.handleChange.bind(this); + + this.state = { + displayNameError: '' + }; + } + componentWillReceiveProps(nextProps) { + if (nextProps.show === true && this.props.show === false) { + this.setState({ + displayNameError: '' + }); + } + } + handleSubmit(e) { + e.preventDefault(); + + const displayName = React.findDOMNode(this.refs.display_name).value.trim(); + if (displayName.length < 1) { + this.setState({displayNameError: 'This field is required'}); + return; + } + + this.props.onSubmitChannel(); + } + handleChange() { + const newData = { + displayName: React.findDOMNode(this.refs.display_name).value, + description: React.findDOMNode(this.refs.channel_desc).value + }; + this.props.onDataChanged(newData); + } + render() { + var displayNameError = null; + var serverError = null; + var displayNameClass = 'form-group'; + + if (this.state.displayNameError) { + displayNameError = <p className='input__help error'>{this.state.displayNameError}</p>; + displayNameClass += ' has-error'; + } + + if (this.props.serverError) { + serverError = <div className='form-group has-error'><p className='input__help error'>{this.props.serverError}</p></div>; + } + + var channelTerm = ''; + var channelSwitchText = ''; + switch (this.props.channelType) { + case 'P': + channelTerm = 'Group'; + channelSwitchText = ( + <div className='modal-intro'> + {'Create a new private group with restricted membership. '} + <a + href='#' + onClick={this.props.onTypeSwitched} + > + {'Create a public channel'} + </a> + </div> + ); + break; + case 'O': + channelTerm = 'Channel'; + channelSwitchText = ( + <div className='modal-intro'> + {'Create a new public channel anyone can join. '} + <a + href='#' + onClick={this.props.onTypeSwitched} + > + {'Create a private group'} + </a> + </div> + ); + break; + } + + const prettyTeamURL = Utils.getShortenedTeamURL(); + + return ( + <span> + <Modal + show={this.props.show} + onHide={this.props.onModalDismissed} + > + <Modal.Header closeButton={true}> + <Modal.Title>{'New ' + channelTerm}</Modal.Title> + </Modal.Header> + <form + role='form' + className='form-horizontal' + > + <Modal.Body> + <div> + {channelSwitchText} + </div> + <div className={displayNameClass}> + <label className='col-sm-2 form__label control-label'>{'Name'}</label> + <div className='col-sm-10'> + <input + onChange={this.handleChange} + type='text' + ref='display_name' + className='form-control' + placeholder='Ex: "Bugs", "Marketing", "办公室恋情"' + maxLength='22' + value={this.props.channelData.displayName} + autoFocus={true} + tabIndex='1' + /> + {displayNameError} + <p className='input__help'> + {'Channel URL: ' + prettyTeamURL + this.props.channelData.name + ' ('} + <a + href='#' + onClick={this.props.onChangeURLPressed} + > + {'Edit'} + </a> + {')'} + </p> + </div> + </div> + <div className='form-group less'> + <div className='col-sm-2'> + <label className='form__label control-label'>{'Description'}</label> + <label className='form__label light'>{'(optional)'}</label> + </div> + <div className='col-sm-10'> + <textarea + className='form-control no-resize' + ref='channel_desc' + rows='4' + placeholder='Description' + maxLength='1024' + value={this.props.channelData.description} + onChange={this.handleChange} + tabIndex='2' + /> + <p className='input__help'> + {'The purpose of your channel. To help others decide whether to join.'} + </p> + {serverError} + </div> + </div> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.props.onModalDismissed} + > + {'Cancel'} + </button> + <button + onClick={this.handleSubmit} + type='submit' + className='btn btn-primary' + tabIndex='3' + > + {'Create New ' + channelTerm} + </button> + </Modal.Footer> + </form> + </Modal> + </span> + ); + } +} + +NewChannelModal.defaultProps = { + show: false, + channelType: 'O', + serverError: '' +}; +NewChannelModal.propTypes = { + show: React.PropTypes.bool.isRequired, + channelType: React.PropTypes.string.isRequired, + channelData: React.PropTypes.object.isRequired, + serverError: React.PropTypes.string, + onSubmitChannel: React.PropTypes.func.isRequired, + onModalDismissed: React.PropTypes.func.isRequired, + onTypeSwitched: React.PropTypes.func.isRequired, + onChangeURLPressed: React.PropTypes.func.isRequired, + onDataChanged: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index d9b8f20ce..df4ed3d57 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -5,6 +5,7 @@ const FileAttachmentList = require('./file_attachment_list.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); +const TextFormatting = require('../utils/text_formatting.jsx'); const twemoji = require('twemoji'); export default class PostBody extends React.Component { @@ -33,7 +34,6 @@ export default class PostBody extends React.Component { const post = this.props.post; const filenames = this.props.post.filenames; const parentPost = this.props.parentPost; - const inner = Utils.textToJsx(this.state.message); let comment = ''; let postClass = ''; @@ -135,7 +135,11 @@ export default class PostBody extends React.Component { key={`${post.id}_message`} className={postClass} > - {loading}<span>{inner}</span> + {loading} + <span + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} + /> </p> {fileAttachmentHolder} {embed} diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index f1a90102c..ed136c01f 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -12,6 +12,7 @@ var FileAttachmentList = require('./file_attachment_list.jsx'); var Client = require('../utils/client.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var ActionTypes = Constants.ActionTypes; +var TextFormatting = require('../utils/text_formatting.jsx'); var twemoji = require('twemoji'); export default class RhsComment extends React.Component { @@ -84,7 +85,6 @@ export default class RhsComment extends React.Component { type = 'Comment'; } - var message = Utils.textToJsx(post.message); var timestamp = UserStore.getCurrentUser().update_at; var loading; @@ -202,7 +202,13 @@ export default class RhsComment extends React.Component { </li> </ul> <div className='post-body'> - <p className={postClass}>{loading}{message}</p> + <p className={postClass}> + {loading} + <span + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> + </p> {fileAttachment} </div> </div> diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index 83b57b955..85755a85c 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -4,6 +4,7 @@ var ChannelStore = require('../stores/channel_store.jsx'); var UserProfile = require('./user_profile.jsx'); var UserStore = require('../stores/user_store.jsx'); +var TextFormatting = require('../utils/text_formatting.jsx'); var utils = require('../utils/utils.jsx'); var FileAttachmentList = require('./file_attachment_list.jsx'); var twemoji = require('twemoji'); @@ -35,7 +36,6 @@ export default class RhsRootPost extends React.Component { } render() { var post = this.props.post; - var message = utils.textToJsx(post.message); var isOwner = UserStore.getCurrentId() === post.user_id; var timestamp = UserStore.getProfile(post.user_id).update_at; var channel = ChannelStore.get(post.channel_id); @@ -140,7 +140,10 @@ export default class RhsRootPost extends React.Component { </li> </ul> <div className='post-body'> - <p>{message}</p> + <p + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> {fileAttachment} </div> </div> diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx index aa56f1174..0e951f5c6 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -10,6 +10,7 @@ var client = require('../utils/client.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var Constants = require('../utils/constants.jsx'); +var TextFormatting = require('../utils/text_formatting.jsx'); var ActionTypes = Constants.ActionTypes; export default class SearchResultsItem extends React.Component { @@ -56,7 +57,6 @@ export default class SearchResultsItem extends React.Component { } render() { - var message = utils.textToJsx(this.props.post.message, {searchTerm: this.props.term, noMentionHighlight: !this.props.isMentionSearch}); var channelName = ''; var channel = ChannelStore.get(this.props.post.channel_id); var timestamp = UserStore.getCurrentUser().update_at; @@ -68,6 +68,11 @@ export default class SearchResultsItem extends React.Component { } } + const formattingOptions = { + searchTerm: this.props.term, + mentionHighlight: this.props.isMentionSearch + }; + return ( <div className='search-item-container post' @@ -91,7 +96,12 @@ export default class SearchResultsItem extends React.Component { </time> </li> </ul> - <div className='search-item-snippet'><span>{message}</span></div> + <div className='search-item-snippet'> + <span + onClick={this.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}} + /> + </div> </div> </div> ); @@ -102,4 +112,4 @@ SearchResultsItem.propTypes = { post: React.PropTypes.object, isMentionSearch: React.PropTypes.bool, term: React.PropTypes.string -};
\ No newline at end of file +}; diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index ad934d271..977fecb5c 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -12,6 +12,7 @@ var Utils = require('../utils/utils.jsx'); var SidebarHeader = require('./sidebar_header.jsx'); var SearchBox = require('./search_bar.jsx'); var Constants = require('../utils/constants.jsx'); +var NewChannelFlow = require('./new_channel_flow.jsx'); export default class Sidebar extends React.Component { constructor(props) { @@ -28,6 +29,7 @@ export default class Sidebar extends React.Component { this.createChannelElement = this.createChannelElement.bind(this); this.state = this.getStateFromStores(); + this.state.modal = ''; this.state.loadingDMChannel = -1; } getStateFromStores() { @@ -473,8 +475,18 @@ export default class Sidebar extends React.Component { ); } + let showChannelModal = false; + if (this.state.modal !== '') { + showChannelModal = true; + } + return ( <div> + <NewChannelFlow + show={showChannelModal} + channelType={this.state.modal} + onModalDismissed={() => this.setState({modal: ''})} + /> <SidebarHeader teamDisplayName={this.props.teamDisplayName} teamType={this.props.teamType} @@ -508,11 +520,9 @@ export default class Sidebar extends React.Component { <a className='add-channel-btn' href='#' - data-toggle='modal' - data-target='#new_channel' - data-channeltype='O' + onClick={() => this.setState({modal: 'O'})} > - + + {'+'} </a> </h4> </li> @@ -537,11 +547,9 @@ export default class Sidebar extends React.Component { <a className='add-channel-btn' href='#' - data-toggle='modal' - data-target='#new_channel' - data-channeltype='P' + onClick={() => this.setState({modal: 'P'})} > - + + {'+'} </a> </h4> </li> diff --git a/web/react/package.json b/web/react/package.json index e55722152..11d60376d 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -7,7 +7,6 @@ "flux": "2.1.1", "keymirror": "0.1.1", "object-assign": "3.0.0", - "react": "0.13.3", "react-zeroclipboard-mixin": "0.1.0", "twemoji": "1.4.1" }, diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 182721bef..e70b51865 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -17,7 +17,6 @@ var RenameChannelModal = require('../components/rename_channel_modal.jsx'); var EditPostModal = require('../components/edit_post_modal.jsx'); var DeletePostModal = require('../components/delete_post_modal.jsx'); var MoreChannelsModal = require('../components/more_channels.jsx'); -var NewChannelModal = require('../components/new_channel.jsx'); var PostDeletedModal = require('../components/post_deleted_modal.jsx'); var ChannelNotificationsModal = require('../components/channel_notifications.jsx'); var UserSettingsModal = require('../components/user_settings_modal.jsx'); @@ -154,11 +153,6 @@ function setupChannelPage(teamName, teamType, teamId, channelName, channelId) { ); React.render( - <NewChannelModal />, - document.getElementById('new_channel_modal') - ); - - React.render( <PostListContainer />, document.getElementById('post-list') ); diff --git a/web/react/stores/admin_store.jsx b/web/react/stores/admin_store.jsx new file mode 100644 index 000000000..591b52d05 --- /dev/null +++ b/web/react/stores/admin_store.jsx @@ -0,0 +1,58 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +var EventEmitter = require('events').EventEmitter; + +var Constants = require('../utils/constants.jsx'); +var ActionTypes = Constants.ActionTypes; + +var LOG_CHANGE_EVENT = 'log_change'; + +class AdminStoreClass extends EventEmitter { + constructor() { + super(); + + this.logs = null; + + this.emitLogChange = this.emitLogChange.bind(this); + this.addLogChangeListener = this.addLogChangeListener.bind(this); + this.removeLogChangeListener = this.removeLogChangeListener.bind(this); + } + + emitLogChange() { + this.emit(LOG_CHANGE_EVENT); + } + + addLogChangeListener(callback) { + this.on(LOG_CHANGE_EVENT, callback); + } + + removeLogChangeListener(callback) { + this.removeListener(LOG_CHANGE_EVENT, callback); + } + + getLogs() { + return this.logs; + } + + saveLogs(logs) { + this.logs = logs; + } +} + +var AdminStore = new AdminStoreClass(); + +AdminStoreClass.dispatchToken = AppDispatcher.register((payload) => { + var action = payload.action; + + switch (action.type) { + case ActionTypes.RECIEVED_LOGS: + AdminStore.saveLogs(action.logs); + AdminStore.emitLogChange(); + break; + default: + } +}); + +export default AdminStore; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 6ccef0506..6b8e73c5a 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -319,6 +319,32 @@ export function getAudits() { ); } +export function getLogs() { + if (isCallInProgress('getLogs')) { + return; + } + + callTracker.getLogs = utils.getTimestamp(); + client.getLogs( + (data, textStatus, xhr) => { + callTracker.getLogs = 0; + + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_LOGS, + logs: data + }); + }, + (err) => { + callTracker.getLogs = 0; + dispatchError(err, 'getLogs'); + } + ); +} + export function findTeams(email) { if (isCallInProgress('findTeams_' + email)) { return; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 51fd16474..75ffdb274 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -294,6 +294,20 @@ export function getAudits(userId, success, error) { }); } +export function getLogs(success, error) { + $.ajax({ + url: '/api/v1/admin/logs', + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success: success, + error: function onError(xhr, status, err) { + var e = handleError('getLogs', xhr, status, err); + error(e); + } + }); +} + export function getMeSynchronous(success, error) { var currentUser = null; $.ajax({ diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 7ead079d7..03e4635b5 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -34,7 +34,9 @@ module.exports = { CLICK_TEAM: null, RECIEVED_TEAM: null, - RECIEVED_CONFIG: null + RECIEVED_CONFIG: null, + + RECIEVED_LOGS: null }), PayloadSources: keyMirror({ diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx new file mode 100644 index 000000000..2c67d7a46 --- /dev/null +++ b/web/react/utils/text_formatting.jsx @@ -0,0 +1,268 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +const Autolinker = require('autolinker'); +const Constants = require('./constants.jsx'); +const UserStore = require('../stores/user_store.jsx'); +const Utils = require('./utils.jsx'); + +// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and +// @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options +// as part of the second parameter: +// - searchTerm - If specified, this word is highlighted in the resulting html. Defaults to nothing. +// - mentionHighlight - Specifies whether or not to highlight mentions of the current user. Defaults to true. +// - singleline - Specifies whether or not to remove newlines. Defaults to false. +export function formatText(text, options = {}) { + let output = sanitizeHtml(text); + const tokens = new Map(); + + // replace important words and phrases with tokens + output = autolinkUrls(output, tokens); + output = autolinkAtMentions(output, tokens); + output = autolinkHashtags(output, tokens); + + if (options.searchTerm) { + output = highlightSearchTerm(output, tokens, options.searchTerm); + } + + if (!('mentionHighlight' in options) || options.mentionHighlight) { + output = highlightCurrentMentions(output, tokens); + } + + // reinsert tokens with formatted versions of the important words and phrases + output = replaceTokens(output, tokens); + + // replace newlines with html line breaks + output = replaceNewlines(output, options.singleline); + + return output; +} + +export function sanitizeHtml(text) { + let output = text; + + // normal string.replace only does a single occurrance so use a regex instead + output = output.replace(/&/g, '&'); + output = output.replace(/</g, '<'); + output = output.replace(/>/g, '>'); + output = output.replace(/'/g, '''); + output = output.replace(/"/g, '"'); + + return output; +} + +function autolinkUrls(text, tokens) { + function replaceUrlWithToken(autolinker, match) { + const linkText = match.getMatchedText(); + let url = linkText; + + if (!url.startsWith('http')) { + url = `http://${linkText}`; + } + + const index = tokens.size; + const alias = `__MM_LINK${index}__`; + + tokens.set(alias, { + value: `<a class='theme' target='_blank' href='${url}'>${linkText}</a>`, + originalText: linkText + }); + + return alias; + } + + // we can't just use a static autolinker because we need to set replaceFn + const autolinker = new Autolinker({ + urls: true, + email: true, + phone: false, + twitter: false, + hashtag: false, + replaceFn: replaceUrlWithToken + }); + + return autolinker.link(text); +} + +function autolinkAtMentions(text, tokens) { + let output = text; + + function replaceAtMentionWithToken(fullMatch, prefix, mention, username) { + const usernameLower = username.toLowerCase(); + if (Constants.SPECIAL_MENTIONS.indexOf(usernameLower) !== -1 || UserStore.getProfileByUsername(usernameLower)) { + const index = tokens.size; + const alias = `__MM_ATMENTION${index}__`; + + tokens.set(alias, { + value: `<a class='mention-link' href='#' data-mention='${usernameLower}'>${mention}</a>`, + originalText: mention + }); + + return prefix + alias; + } + + return fullMatch; + } + + output = output.replace(/(^|\s)(@([a-z0-9.\-_]*[a-z0-9]))/gi, replaceAtMentionWithToken); + + return output; +} + +function highlightCurrentMentions(text, tokens) { + let output = text; + + const mentionKeys = UserStore.getCurrentMentionKeys(); + + // look for any existing tokens which are self mentions and should be highlighted + var newTokens = new Map(); + for (const [alias, token] of tokens) { + if (mentionKeys.indexOf(token.originalText) !== -1) { + const index = tokens.size + newTokens.size; + const newAlias = `__MM_SELFMENTION${index}__`; + + newTokens.set(newAlias, { + value: `<span class='mention-highlight'>${alias}</span>`, + originalText: token.originalText + }); + + output = output.replace(alias, newAlias); + } + } + + // the new tokens are stashed in a separate map since we can't add objects to a map during iteration + for (const newToken of newTokens) { + tokens.set(newToken[0], newToken[1]); + } + + // look for self mentions in the text + function replaceCurrentMentionWithToken(fullMatch, prefix, mention) { + const index = tokens.size; + const alias = `__MM_SELFMENTION${index}__`; + + tokens.set(alias, { + value: `<span class='mention-highlight'>${mention}</span>`, + originalText: mention + }); + + return prefix + alias; + } + + for (const mention of UserStore.getCurrentMentionKeys()) { + output = output.replace(new RegExp(`(^|\\W)(${mention})\\b`, 'gi'), replaceCurrentMentionWithToken); + } + + return output; +} + +function autolinkHashtags(text, tokens) { + let output = text; + + var newTokens = new Map(); + for (const [alias, token] of tokens) { + if (token.originalText.startsWith('#')) { + const index = tokens.size + newTokens.size; + const newAlias = `__MM_HASHTAG${index}__`; + + newTokens.set(newAlias, { + value: `<a class='mention-link' href='#' data-hashtag='${token.originalText}'>${token.originalText}</a>`, + originalText: token.originalText + }); + + output = output.replace(alias, newAlias); + } + } + + // the new tokens are stashed in a separate map since we can't add objects to a map during iteration + for (const newToken of newTokens) { + tokens.set(newToken[0], newToken[1]); + } + + // look for hashtags in the text + function replaceHashtagWithToken(fullMatch, prefix, hashtag) { + const index = tokens.size; + const alias = `__MM_HASHTAG${index}__`; + + tokens.set(alias, { + value: `<a class='mention-link' href='#' data-hashtag='${hashtag}'>${hashtag}</a>`, + originalText: hashtag + }); + + return prefix + alias; + } + + return output.replace(/(^|\W)(#[a-zA-Z0-9.\-_]+)\b/g, replaceHashtagWithToken); +} + +function highlightSearchTerm(text, tokens, searchTerm) { + let output = text; + + var newTokens = new Map(); + for (const [alias, token] of tokens) { + if (token.originalText === searchTerm) { + const index = tokens.size + newTokens.size; + const newAlias = `__MM_SEARCHTERM${index}__`; + + newTokens.set(newAlias, { + value: `<span class='search-highlight'>${alias}</span>`, + originalText: token.originalText + }); + + output = output.replace(alias, newAlias); + } + } + + // the new tokens are stashed in a separate map since we can't add objects to a map during iteration + for (const newToken of newTokens) { + tokens.set(newToken[0], newToken[1]); + } + + function replaceSearchTermWithToken(fullMatch, prefix, word) { + const index = tokens.size; + const alias = `__MM_SEARCHTERM${index}__`; + + tokens.set(alias, { + value: `<span class='search-highlight'>${word}</span>`, + originalText: word + }); + + return prefix + alias; + } + + return output.replace(new RegExp(`(^|\\W)(${searchTerm})\\b`, 'gi'), replaceSearchTermWithToken); +} + +function replaceTokens(text, tokens) { + let output = text; + + // iterate backwards through the map so that we do replacement in the opposite order that we added tokens + const aliases = [...tokens.keys()]; + for (let i = aliases.length - 1; i >= 0; i--) { + const alias = aliases[i]; + const token = tokens.get(alias); + output = output.replace(alias, token.value); + } + + return output; +} + +function replaceNewlines(text, singleline) { + if (!singleline) { + return text.replace(/\n/g, '<br />'); + } + + return text.replace(/\n/g, ' '); +} + +// A click handler that can be used with the results of TextFormatting.formatText to add default functionality +// to clicked hashtags and @mentions. +export function handleClick(e) { + const mentionAttribute = e.target.getAttributeNode('data-mention'); + const hashtagAttribute = e.target.getAttributeNode('data-hashtag'); + + if (mentionAttribute) { + Utils.searchForTerm(mentionAttribute.value); + } else if (hashtagAttribute) { + Utils.searchForTerm(hashtagAttribute.value); + } +} diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 54d05f484..c2307f5e9 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -434,205 +434,6 @@ export function searchForTerm(term) { }); } -var puncStartRegex = /^((?![@#])\W)+/g; -var puncEndRegex = /(\W)+$/g; - -export function textToJsx(textin, options) { - var text = textin; - if (options && options.singleline) { - var repRegex = new RegExp('\n', 'g'); //eslint-disable-line no-control-regex - text = text.replace(repRegex, ' '); - } - - var searchTerm = ''; - if (options && options.searchTerm) { - searchTerm = options.searchTerm.toLowerCase(); - } - - var mentionClass = 'mention-highlight'; - if (options && options.noMentionHighlight) { - mentionClass = ''; - } - - var inner = []; - - // Function specific regex - var hashRegex = /^href="#[^']+"|(^#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$/g; - - var implicitKeywords = UserStore.getCurrentMentionKeys(); - - var lines = text.split('\n'); - for (let i = 0; i < lines.length; i++) { - var line = lines[i]; - var words = line.split(' '); - var highlightSearchClass = ''; - for (let z = 0; z < words.length; z++) { - var word = words[z]; - var trimWord = word.replace(puncStartRegex, '').replace(puncEndRegex, '').trim(); - var mentionRegex = /^(?:@)([a-z0-9_]+)$/gi; // looks loop invariant but a weird JS bug needs it to be redefined here - var explicitMention = mentionRegex.exec(trimWord); - - if (searchTerm !== '') { - let searchWords = searchTerm.split(' '); - for (let idx in searchWords) { - if ({}.hasOwnProperty.call(searchWords, idx)) { - let searchWord = searchWords[idx]; - if (searchWord === word.toLowerCase() || searchWord === trimWord.toLowerCase()) { - highlightSearchClass = ' search-highlight'; - break; - } else if (searchWord.charAt(searchWord.length - 1) === '*') { - let searchWordPrefix = searchWord.slice(0, -1); - if (trimWord.toLowerCase().indexOf(searchWordPrefix) > -1 || word.toLowerCase().indexOf(searchWordPrefix) > -1) { - highlightSearchClass = ' search-highlight'; - break; - } - } - } - } - } - - if (explicitMention && - (UserStore.getProfileByUsername(explicitMention[1]) || - Constants.SPECIAL_MENTIONS.indexOf(explicitMention[1]) !== -1)) { - let name = explicitMention[1]; - - // do both a non-case sensitive and case senstive check - let mClass = ''; - if (implicitKeywords.indexOf('@' + name.toLowerCase()) !== -1 || implicitKeywords.indexOf('@' + name) !== -1) { - mClass = mentionClass; - } - - let suffix = word.match(puncEndRegex); - let prefix = word.match(puncStartRegex); - - if (searchTerm === name) { - highlightSearchClass = ' search-highlight'; - } - - inner.push( - <span key={name + i + z + '_span'}> - {prefix} - <a - className={mClass + highlightSearchClass + ' mention-link'} - key={name + i + z + '_link'} - href='#' - onClick={() => searchForTerm(name)} //eslint-disable-line no-loop-func - > - @{name} - </a> - {suffix} - {' '} - </span> - ); - } else if (testUrlMatch(word).length) { - let match = testUrlMatch(word)[0]; - let link = match.link; - - let prefix = word.substring(0, word.indexOf(match.text)); - let suffix = word.substring(word.indexOf(match.text) + match.text.length); - - inner.push( - <span key={word + i + z + '_span'}> - {prefix} - <a - key={word + i + z + '_link'} - className={'theme' + highlightSearchClass} - target='_blank' - href={link} - > - {match.text} - </a> - {suffix} - {' '} - </span> - ); - } else if (trimWord.match(hashRegex)) { - let suffix = word.match(puncEndRegex); - let prefix = word.match(puncStartRegex); - let mClass = ''; - if (implicitKeywords.indexOf(trimWord) !== -1 || implicitKeywords.indexOf(trimWord.toLowerCase()) !== -1) { - mClass = mentionClass; - } - - if (searchTerm === trimWord.substring(1).toLowerCase() || searchTerm === trimWord.toLowerCase()) { - highlightSearchClass = ' search-highlight'; - } - - inner.push( - <span key={word + i + z + '_span'}> - {prefix} - <a - key={word + i + z + '_hash'} - className={'theme ' + mClass + highlightSearchClass} - href='#' - onClick={searchForTerm.bind(this, trimWord)} //eslint-disable-line no-loop-func - > - {trimWord} - </a> - {suffix} - {' '} - </span> - ); - } else if (implicitKeywords.indexOf(trimWord) !== -1 || implicitKeywords.indexOf(trimWord.toLowerCase()) !== -1) { - let suffix = word.match(puncEndRegex); - let prefix = word.match(puncStartRegex); - - if (trimWord.charAt(0) === '@') { - if (searchTerm === trimWord.substring(1).toLowerCase()) { - highlightSearchClass = ' search-highlight'; - } - inner.push( - <span key={word + i + z + '_span'}> - {prefix} - <a - className={mentionClass + highlightSearchClass} - key={name + i + z + '_link'} - href='#' - > - {trimWord} - </a> - {suffix} - {' '} - </span> - ); - } else { - inner.push( - <span key={word + i + z + '_span'}> - {prefix} - <span className={mentionClass + highlightSearchClass}> - {replaceHtmlEntities(trimWord)} - </span> - {suffix} - {' '} - </span> - ); - } - } else if (word === '') { - - // if word is empty dont include a span - - } else { - inner.push( - <span key={word + i + z + '_span'}> - <span className={highlightSearchClass}> - {replaceHtmlEntities(word)} - </span> - {' '} - </span> - ); - } - highlightSearchClass = ''; - } - if (i !== lines.length - 1) { - inner.push( - <br key={'br_' + i}/> - ); - } - } - - return inner; -} - export function getFileType(extin) { var ext = extin.toLowerCase(); if (Constants.IMAGE_TYPES.indexOf(ext) > -1) { @@ -1125,3 +926,14 @@ export function importSlack(file, success, error) { client.importSlack(formData, success, error); } + +export function getTeamURLFromAddressBar() { + return window.location.href.split('/channels')[0]; +} + +export function getShortenedTeamURL() { + const teamURL = getTeamURLFromAddressBar(); + if (teamURL.length > 24) { + return teamURL.substring(0, 10) + '...' + teamURL.substring(teamURL.length - 12, teamURL.length) + '/'; + } +} |