summaryrefslogtreecommitdiffstats
path: root/web/react
diff options
context:
space:
mode:
Diffstat (limited to 'web/react')
-rw-r--r--web/react/.eslintrc3
-rw-r--r--web/react/components/admin_console/admin_controller.jsx3
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx10
-rw-r--r--web/react/components/admin_console/logs.jsx88
-rw-r--r--web/react/components/change_url_modal.jsx177
-rw-r--r--web/react/components/channel_header.jsx8
-rw-r--r--web/react/components/file_upload.jsx2
-rw-r--r--web/react/components/message_wrapper.jsx10
-rw-r--r--web/react/components/navbar.jsx2
-rw-r--r--web/react/components/new_channel.jsx211
-rw-r--r--web/react/components/new_channel_flow.jsx206
-rw-r--r--web/react/components/new_channel_modal.jsx198
-rw-r--r--web/react/components/post_body.jsx8
-rw-r--r--web/react/components/rhs_comment.jsx10
-rw-r--r--web/react/components/rhs_root_post.jsx7
-rw-r--r--web/react/components/search_results_item.jsx16
-rw-r--r--web/react/components/sidebar.jsx24
-rw-r--r--web/react/package.json1
-rw-r--r--web/react/pages/channel.jsx6
-rw-r--r--web/react/stores/admin_store.jsx58
-rw-r--r--web/react/utils/async_client.jsx26
-rw-r--r--web/react/utils/client.jsx14
-rw-r--r--web/react/utils/constants.jsx4
-rw-r--r--web/react/utils/text_formatting.jsx268
-rw-r--r--web/react/utils/utils.jsx210
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'>&times;</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, '&amp;');
+ output = output.replace(/</g, '&lt;');
+ output = output.replace(/>/g, '&gt;');
+ output = output.replace(/'/g, '&apos;');
+ output = output.replace(/"/g, '&quot;');
+
+ 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) + '/';
+ }
+}