summaryrefslogtreecommitdiffstats
path: root/web/react
diff options
context:
space:
mode:
Diffstat (limited to 'web/react')
-rw-r--r--web/react/components/channel_header.jsx249
-rw-r--r--web/react/components/channel_info_modal.jsx50
-rw-r--r--web/react/components/channel_invite_modal.jsx157
-rw-r--r--web/react/components/channel_loader.jsx62
-rw-r--r--web/react/components/channel_members.jsx154
-rw-r--r--web/react/components/channel_notifications.jsx120
-rw-r--r--web/react/components/command_list.jsx67
-rw-r--r--web/react/components/create_comment.jsx166
-rw-r--r--web/react/components/create_post.jsx273
-rw-r--r--web/react/components/delete_channel_modal.jsx58
-rw-r--r--web/react/components/delete_post_modal.jsx108
-rw-r--r--web/react/components/edit_channel_modal.jsx57
-rw-r--r--web/react/components/edit_post_modal.jsx100
-rw-r--r--web/react/components/email_verify.jsx35
-rw-r--r--web/react/components/error_bar.jsx69
-rw-r--r--web/react/components/file_preview.jsx54
-rw-r--r--web/react/components/file_upload.jsx129
-rw-r--r--web/react/components/find_team.jsx72
-rw-r--r--web/react/components/get_link_modal.jsx55
-rw-r--r--web/react/components/invite_member_modal.jsx179
-rw-r--r--web/react/components/login.jsx197
-rw-r--r--web/react/components/member_list.jsx34
-rw-r--r--web/react/components/member_list_item.jsx67
-rw-r--r--web/react/components/member_list_team.jsx120
-rw-r--r--web/react/components/mention.jsx16
-rw-r--r--web/react/components/mention_list.jsx127
-rw-r--r--web/react/components/message_wrapper.jsx17
-rw-r--r--web/react/components/more_channels.jsx109
-rw-r--r--web/react/components/more_direct_channels.jsx68
-rw-r--r--web/react/components/msg_typing.jsx49
-rw-r--r--web/react/components/navbar.jsx351
-rw-r--r--web/react/components/new_channel.jsx139
-rw-r--r--web/react/components/password_reset.jsx178
-rw-r--r--web/react/components/post.jsx88
-rw-r--r--web/react/components/post_body.jsx141
-rw-r--r--web/react/components/post_deleted_modal.jsx36
-rw-r--r--web/react/components/post_header.jsx23
-rw-r--r--web/react/components/post_info.jsx52
-rw-r--r--web/react/components/post_list.jsx474
-rw-r--r--web/react/components/post_right.jsx397
-rw-r--r--web/react/components/rename_channel_modal.jsx142
-rw-r--r--web/react/components/rename_team_modal.jsx92
-rw-r--r--web/react/components/search_bar.jsx104
-rw-r--r--web/react/components/search_results.jsx180
-rw-r--r--web/react/components/setting_item_max.jsx31
-rw-r--r--web/react/components/setting_item_min.jsx14
-rw-r--r--web/react/components/setting_picture.jsx55
-rw-r--r--web/react/components/settings_modal.jsx59
-rw-r--r--web/react/components/settings_sidebar.jsx24
-rw-r--r--web/react/components/sidebar.jsx449
-rw-r--r--web/react/components/sidebar_header.jsx134
-rw-r--r--web/react/components/sidebar_right.jsx84
-rw-r--r--web/react/components/sidebar_right_menu.jsx76
-rw-r--r--web/react/components/signup_team.jsx78
-rw-r--r--web/react/components/signup_team_complete.jsx644
-rw-r--r--web/react/components/signup_user_complete.jsx145
-rw-r--r--web/react/components/team_members.jsx78
-rw-r--r--web/react/components/textbox.jsx290
-rw-r--r--web/react/components/user_profile.jsx71
-rw-r--r--web/react/components/user_settings.jsx1151
-rw-r--r--web/react/components/view_image.jsx189
-rw-r--r--web/react/dispatcher/app_dispatcher.jsx30
-rw-r--r--web/react/package.json37
-rw-r--r--web/react/pages/channel.jsx197
-rw-r--r--web/react/pages/find_team.jsx13
-rw-r--r--web/react/pages/home.jsx14
-rw-r--r--web/react/pages/login.jsx11
-rw-r--r--web/react/pages/password_reset.jsx19
-rw-r--r--web/react/pages/signup_team.jsx11
-rw-r--r--web/react/pages/signup_team_complete.jsx11
-rw-r--r--web/react/pages/signup_user_complete.jsx11
-rw-r--r--web/react/pages/verify.jsx13
-rw-r--r--web/react/stores/channel_store.jsx255
-rw-r--r--web/react/stores/error_store.jsx59
-rw-r--r--web/react/stores/post_store.jsx224
-rw-r--r--web/react/stores/socket_store.jsx86
-rw-r--r--web/react/stores/user_store.jsx328
-rw-r--r--web/react/utils/async_client.jsx357
-rw-r--r--web/react/utils/client.jsx813
-rw-r--r--web/react/utils/constants.jsx78
-rw-r--r--web/react/utils/utils.jsx732
81 files changed, 12286 insertions, 0 deletions
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
new file mode 100644
index 000000000..006c168ba
--- /dev/null
+++ b/web/react/components/channel_header.jsx
@@ -0,0 +1,249 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var UserProfile = require( './user_profile.jsx' );
+var NavbarSearchBox =require('./search_bar.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var Client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+var MessageWrapper = require('./message_wrapper.jsx');
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+function getExtraInfoStateFromStores() {
+ return {
+ extra_info: ChannelStore.getCurrentExtraInfo()
+ };
+}
+
+var ExtraMembers = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addExtraInfoChangeListener(this._onChange);
+ ChannelStore.addChangeListener(this._onChange);
+
+ var originalLeave = $.fn.popover.Constructor.prototype.leave;
+ $.fn.popover.Constructor.prototype.leave = function(obj) {
+ var self = obj instanceof this.constructor ? obj : $(obj.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type);
+ originalLeave.call(this, obj);
+
+ if (obj.currentTarget && self.$tip) {
+ self.$tip.one('mouseenter', function() {
+ clearTimeout(self.timeout);
+ self.$tip.one('mouseleave', function() {
+ $.fn.popover.Constructor.prototype.leave.call(self, self);
+ });
+ })
+ }
+ };
+
+ $("#member_popover").popover({placement : 'bottom', trigger: 'click', html: true});
+ $('body').on('click', function (e) {
+ if ($(e.target.parentNode.parentNode)[0] !== $("#member_popover")[0] && $(e.target).parents('.popover.in').length === 0) {
+ $("#member_popover").popover('hide');
+ }
+ });
+
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeExtraInfoChangeListener(this._onChange);
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getExtraInfoStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ },
+ getInitialState: function() {
+ return getExtraInfoStateFromStores();
+ },
+ render: function() {
+ var count = this.state.extra_info.members.length == 0 ? "-" : this.state.extra_info.members.length;
+ count = this.state.extra_info.members.length > 19 ? "20+" : count;
+ var data_content = "";
+
+ this.state.extra_info.members.forEach(function(m) {
+ data_content += "<div style='white-space: nowrap'>" + m.username + "</div>";
+ });
+
+ return (
+ <div style={{"cursor" : "pointer"}} id="member_popover" data-toggle="popover" data-content={data_content} data-original-title="Members" >
+ <div id="member_tooltip" data-toggle="tooltip" title="View Channel Members">
+ {count} <span className="glyphicon glyphicon-user" aria-hidden="true"></span>
+ </div>
+ </div>
+ );
+ }
+});
+
+function getStateFromStores() {
+ return {
+ channel: ChannelStore.getCurrent(),
+ memberChannel: ChannelStore.getCurrentMember(),
+ memberTeam: UserStore.getCurrentUser(),
+ users: ChannelStore.getCurrentExtraInfo().members,
+ search_visible: PostStore.getSearchResults() != null
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+ ChannelStore.addExtraInfoChangeListener(this._onChange);
+ PostStore.addSearchChangeListener(this._onChange);
+ UserStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ ChannelStore.removeExtraInfoChangeListener(this._onChange);
+ PostStore.removeSearchChangeListener(this._onChange);
+ UserStore.addChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ $(".channel-header__info .description").popover({placement : 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}});
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ handleLeave: function(e) {
+ var self = this;
+ Client.leaveChannel(this.state.channel.id,
+ function(data) {
+ var townsquare = ChannelStore.getByName('town-square');
+ utils.switchChannel(townsquare);
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "handleLeave");
+ }.bind(this)
+ );
+ },
+ searchMentions: function(e) {
+ e.preventDefault();
+
+ var user = UserStore.getCurrentUser();
+
+ var terms = "";
+ if (user.notify_props && user.notify_props.mention_keys) {
+ terms = UserStore.getCurrentMentionKeys().join(' ');
+ }
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH_TERM,
+ term: terms,
+ do_search: false
+ });
+
+ Client.search(
+ terms,
+ function(data) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: data,
+ is_mention_search: true
+ });
+ },
+ function(err) {
+ dispatchError(err, "search");
+ }
+ );
+ },
+ render: function() {
+
+ if (this.state.channel == null) {
+ return (
+ <div></div>
+ );
+ }
+
+ var description = utils.textToJsx(this.state.channel.description, {"singleline": true, "noMentionHighlight": true});
+ var popoverContent = React.renderToString(<MessageWrapper message={this.state.channel.description}/>);
+ var channelTitle = "";
+ var channelName = this.state.channel.name;
+ var currentId = UserStore.getCurrentId();
+ var isAdmin = this.state.memberChannel.roles.indexOf("admin") > -1 || this.state.memberTeam.roles.indexOf("admin") > -1;
+ var searchForm = <th className="search-bar__container"><NavbarSearchBox /></th>;
+ var isDirect = false;
+
+ if (this.state.channel.type === 'O') {
+ channelTitle = this.state.channel.display_name;
+ } else if (this.state.channel.type === 'P') {
+ channelTitle = this.state.channel.display_name;
+ } else if (this.state.channel.type === 'D') {
+ isDirect = true;
+ if (this.state.users.length > 1) {
+ if (this.state.users[0].id === UserStore.getCurrentId()) {
+ channelTitle = <UserProfile userId={this.state.users[1].id} overwriteName={this.state.users[1].full_name ? this.state.users[1].full_name : this.state.users[1].username} />;
+ } else {
+ channelTitle = <UserProfile userId={this.state.users[0].id} overwriteName={this.state.users[0].full_name ? this.state.users[0].full_name : this.state.users[0].username} />;
+ }
+ }
+ }
+
+ return (
+ <table className="channel-header alt">
+ <tr>
+ <th>
+ { !isDirect ?
+ <div className="channel-header__info">
+ <div className="dropdown">
+ <a href="#" className="dropdown-toggle theme" type="button" id="channel_header_dropdown" data-toggle="dropdown" aria-expanded="true">
+ <strong className="heading">{channelTitle} </strong>
+ <span className="glyphicon glyphicon-chevron-down header-dropdown__icon"></span>
+ </a>
+ <ul className="dropdown-menu" role="menu" aria-labelledby="channel_header_dropdown">
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_info" data-channelid={this.state.channel.id} href="#">View Info</a></li>
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Invite Members</a></li>
+ { isAdmin ?
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_members" href="#">Manage Members</a></li>
+ : ""
+ }
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#edit_channel" data-desc={this.state.channel.description} data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Set Channel Description...</a></li>
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#channel_notifications" data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Notification Preferences</a></li>
+ { isAdmin && channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#rename_channel" data-display={this.state.channel.display_name} data-name={this.state.channel.name} data-channelid={this.state.channel.id}>Rename Channel...</a></li>
+ : ""
+ }
+ { isAdmin && channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#delete_channel" data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Delete Channel...</a></li>
+ : ""
+ }
+ { channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" onClick={this.handleLeave}>Leave Channel</a></li>
+ : ""
+ }
+ </ul>
+ </div>
+ <div data-toggle="popover" data-content={popoverContent} className="description">{description}</div>
+ </div>
+ :
+ <a href="#"><strong className="heading">{channelTitle}</strong></a>
+ }
+ </th>
+ <th><ExtraMembers channelId={this.state.channel.id} /></th>
+ { searchForm }
+ <th>
+ <div className="dropdown" style={{"marginLeft":"5px", "marginRight":"10px"}}>
+ <a href="#" className="dropdown-toggle theme" type="button" id="channel_header_right_dropdown" data-toggle="dropdown" aria-expanded="true">
+ <i className="fa fa-caret-down"></i>
+ </a>
+ <ul className="dropdown-menu" role="menu" aria-labelledby="channel_header_right_dropdown" style={{"left": "-150px"}}>
+ <li role="presentation"><a role="menuitem" href="#" onClick={this.searchMentions}>Recent Mentions</a></li>
+ </ul>
+ </div>
+ </th>
+ </tr>
+ </table>
+ );
+ }
+});
+
+
diff --git a/web/react/components/channel_info_modal.jsx b/web/react/components/channel_info_modal.jsx
new file mode 100644
index 000000000..191297ce4
--- /dev/null
+++ b/web/react/components/channel_info_modal.jsx
@@ -0,0 +1,50 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ var self = this;
+ if(this.refs.modal) {
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ channel_id: $(button).attr('data-channelid') });
+ });
+ }
+ },
+ getInitialState: function() {
+ return { channel_id: ChannelStore.getCurrentId() };
+ },
+ render: function() {
+ var channel = ChannelStore.get(this.state.channel_id);
+
+ if (!channel) {
+ channel = {};
+ channel.display_name = "No Channel Found";
+ channel.name = "No Channel Found";
+ channel.id = "No Channel Found";
+ }
+
+ return (
+ <div className="modal fade" ref="modal" id="channel_info" 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" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" id="myModalLabel">{channel.display_name}</h4>
+ </div>
+ <div className="modal-body">
+ <p><strong>Channel Name: </strong>{channel.display_name}</p>
+ <p><strong>Channel Handle: </strong>{channel.name}</p>
+ <p><strong>Channel ID: </strong>{channel.id}</p>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx
new file mode 100644
index 000000000..d41453fab
--- /dev/null
+++ b/web/react/components/channel_invite_modal.jsx
@@ -0,0 +1,157 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var MemberList = require('./member_list.jsx');
+var utils = require('../utils/utils.jsx');
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+
+function getStateFromStores() {
+ var users = UserStore.getActiveOnlyProfiles();
+ var member_list = ChannelStore.getCurrentExtraInfo().members;
+
+ var nonmember_list = [];
+ for (var id in users) {
+ var found = false;
+ for (var i = 0; i < member_list.length; i++) {
+ if (member_list[i].id === id) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ nonmember_list.push(users[id]);
+ }
+ }
+
+ member_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+
+ nonmember_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+
+ var channel_name = ChannelStore.getCurrent() ? ChannelStore.getCurrent().display_name : "";
+
+ return {
+ nonmember_list: nonmember_list,
+ member_list: member_list,
+ channel_name: channel_name
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addExtraInfoChangeListener(this._onChange);
+ ChannelStore.addChangeListener(this._onChange);
+
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
+ self.setState({ render_members: false });
+ });
+
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ self.setState({ render_members: true });
+ });
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeExtraInfoChangeListener(this._onChange);
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var new_state = getStateFromStores();
+ if (!utils.areStatesEqual(this.state, new_state)) {
+ this.setState(new_state);
+ }
+ },
+ handleInvite: function(user_id) {
+ // Make sure the user isn't already a member of the channel
+ var member_list = this.state.member_list;
+ for (var i = 0; i < member_list; i++) {
+ if (member_list[i].id === user_id) {
+ return;
+ }
+ }
+
+ var data = {};
+ data['user_id'] = user_id;
+
+ client.addChannelMember(ChannelStore.getCurrentId(), data,
+ function(data) {
+ var nonmember_list = this.state.nonmember_list;
+ var new_member;
+ for (var i = 0; i < nonmember_list.length; i++) {
+ if (user_id === nonmember_list[i].id) {
+ nonmember_list[i].invited = true;
+ new_member = nonmember_list[i];
+ break;
+ }
+ }
+
+ if (new_member) {
+ member_list.push(new_member);
+ member_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+ }
+
+ this.setState({ invite_error: null, member_list: member_list, nonmember_list: nonmember_list });
+ AsyncClient.getChannelExtraInfo(true);
+ }.bind(this),
+ function(err) {
+ this.setState({ invite_error: err.message });
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var invite_error = this.state.invite_error ? <label className='has-error control-label'>{this.state.invite_error}</label> : null;
+
+ var currentMember = ChannelStore.getCurrentMember();
+ var isAdmin = false;
+ if (currentMember) {
+ isAdmin = currentMember.roles.indexOf("admin") > -1 || UserStore.getCurrentUser().roles.indexOf("admin") > -1;
+ }
+
+ return (
+ <div className="modal fade" ref="modal" id="channel_invite" 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" aria-label="Close"><span aria-hidden="true">×</span></button>
+ <h4 className="modal-title">Add New Members to {this.state.channel_name}</h4>
+ </div>
+ <div className="modal-body">
+ { invite_error }
+ { this.state.render_members ?
+ <MemberList
+ memberList={this.state.nonmember_list}
+ isAdmin={isAdmin}
+ handleInvite={this.handleInvite}
+ />
+ : "" }
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
+
+
+
diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx
new file mode 100644
index 000000000..5252f275c
--- /dev/null
+++ b/web/react/components/channel_loader.jsx
@@ -0,0 +1,62 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+/* This is a special React control with the sole purpose of making all the AsyncClient calls
+ to the server on page load. This is to prevent other React controls from spamming
+ AsyncClient with requests. */
+
+var AsyncClient = require('../utils/async_client.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var Constants = require('../utils/constants.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ /* Start initial aysnc loads */
+ AsyncClient.getMe();
+ AsyncClient.getPosts(true);
+ AsyncClient.getChannels(true, true);
+ AsyncClient.getChannelExtraInfo(true);
+ AsyncClient.findTeams();
+ AsyncClient.getStatuses();
+ /* End of async loads */
+
+
+ /* Start interval functions */
+ setInterval(function(){AsyncClient.getStatuses();}, 30000);
+ /* End interval functions */
+
+
+ /* Start device tracking setup */
+ var iOS = /(iPad|iPhone|iPod)/g.test( navigator.userAgent );
+ if (iOS) {
+ $("body").addClass("ios");
+ }
+ /* End device tracking setup */
+
+
+ /* Start window active tracking setup */
+ window.isActive = true;
+
+ $(window).focus(function() {
+ AsyncClient.updateLastViewedAt();
+ window.isActive = true;
+ });
+
+ $(window).blur(function() {
+ window.isActive = false;
+ });
+ /* End window active tracking setup */
+
+ /* Start global change listeners setup */
+ SocketStore.addChangeListener(this._onSocketChange);
+ /* End global change listeners setup */
+ },
+ _onSocketChange: function(msg) {
+ if (msg && msg.user_id) {
+ UserStore.setStatus(msg.user_id, "online");
+ }
+ },
+ render: function() {
+ return <div/>;
+ }
+});
diff --git a/web/react/components/channel_members.jsx b/web/react/components/channel_members.jsx
new file mode 100644
index 000000000..cfb8ed41c
--- /dev/null
+++ b/web/react/components/channel_members.jsx
@@ -0,0 +1,154 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var MemberList = require('./member_list.jsx');
+var client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+
+function getStateFromStores() {
+ var users = UserStore.getActiveOnlyProfiles();
+ var member_list = ChannelStore.getCurrentExtraInfo().members;
+
+ var nonmember_list = [];
+ for (var id in users) {
+ var found = false;
+ for (var i = 0; i < member_list.length; i++) {
+ if (member_list[i].id === id) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ nonmember_list.push(users[id]);
+ }
+ }
+
+ member_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+
+ nonmember_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+
+ var channel_name = ChannelStore.getCurrent() ? ChannelStore.getCurrent().display_name : "";
+
+ return {
+ nonmember_list: nonmember_list,
+ member_list: member_list,
+ channel_name: channel_name
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addExtraInfoChangeListener(this._onChange);
+ ChannelStore.addChangeListener(this._onChange);
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
+ self.setState({ render_members: false });
+ });
+
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ self.setState({ render_members: true });
+ });
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeExtraInfoChangeListener(this._onChange);
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var new_state = getStateFromStores();
+ if (!utils.areStatesEqual(this.state, new_state)) {
+ this.setState(new_state);
+ }
+ },
+ handleRemove: function(user_id) {
+ // Make sure the user is a member of the channel
+ var member_list = this.state.member_list;
+ var found = false;
+ for (var i = 0; i < member_list.length; i++) {
+ if (member_list[i].id === user_id) {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found) { return };
+
+ var data = {};
+ data['user_id'] = user_id;
+
+ client.removeChannelMember(ChannelStore.getCurrentId(), data,
+ function(data) {
+ var old_member;
+ for (var i = 0; i < member_list.length; i++) {
+ if (user_id === member_list[i].id) {
+ old_member = member_list[i];
+ member_list.splice(i, 1);
+ break;
+ }
+ }
+
+ var nonmember_list = this.state.nonmember_list;
+ if (old_member) {
+ nonmember_list.push(old_member);
+ }
+
+ this.setState({ member_list: member_list, nonmember_list: nonmember_list });
+ AsyncClient.getChannelExtraInfo(true);
+ }.bind(this),
+ function(err) {
+ this.setState({ invite_error: err.message });
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var currentMember = ChannelStore.getCurrentMember();
+ var isAdmin = false;
+ if (currentMember) {
+ isAdmin = currentMember.roles.indexOf("admin") > -1 || UserStore.getCurrentUser().roles.indexOf("admin") > -1;
+ }
+
+ return (
+ <div className="modal fade" ref="modal" id="channel_members" 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" aria-label="Close"><span aria-hidden="true">×</span></button>
+ <h4 className="modal-title">{this.state.channel_name + " Members"}</h4>
+ <a className="btn btn-md btn-primary" data-toggle="modal" data-target="#channel_invite"><i className="glyphicon glyphicon-envelope"/> Add New Members</a>
+ </div>
+ <div ref="modalBody" className="modal-body">
+ <div className="col-sm-12">
+ <div className="team-member-list">
+ { this.state.render_members ?
+ <MemberList
+ memberList={this.state.member_list}
+ isAdmin={isAdmin}
+ handleRemove={this.handleRemove}
+ />
+ : "" }
+ </div>
+ </div>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ );
+ }
+});
diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx
new file mode 100644
index 000000000..085536a0a
--- /dev/null
+++ b/web/react/components/channel_notifications.jsx
@@ -0,0 +1,120 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ var channel_id = button.dataset.channelid;
+
+ var notifyLevel = ChannelStore.getMember(channel_id).notify_level;
+ self.setState({ notify_level: notifyLevel, title: button.dataset.title, channel_id: channel_id });
+ });
+ },
+ getInitialState: function() {
+ return { notify_level: "", title: "", channel_id: "" };
+ },
+ handleUpdate: function(e) {
+ var channel_id = this.state.channel_id;
+ var notify_level = this.state.notify_level;
+
+ var data = {};
+ data["channel_id"] = channel_id;
+ data["user_id"] = UserStore.getCurrentId();
+ data["notify_level"] = this.state.notify_level;
+
+ if (!data["notify_level"] || data["notify_level"].length === 0) return;
+
+ client.updateNotifyLevel(data,
+ function(data) {
+ var member = ChannelStore.getMember(channel_id);
+ member.notify_level = notify_level;
+ ChannelStore.setChannelMember(member);
+ $(this.refs.modal.getDOMNode()).modal('hide');
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ handleRadioClick: function(notifyLevel) {
+ this.setState({ notify_level: notifyLevel });
+ this.refs.modal.getDOMNode().focus();
+ },
+ handleQuietToggle: function() {
+ if (this.state.notify_level === "quiet") {
+ this.setState({ notify_level: "none" });
+ this.refs.modal.getDOMNode().focus();
+ } else {
+ this.setState({ notify_level: "quiet" });
+ this.refs.modal.getDOMNode().focus();
+ }
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ var allActive = "";
+ var mentionActive = "";
+ var noneActive = "";
+ var quietActive = "";
+ var desktopHidden = "";
+
+ if (this.state.notify_level === "quiet") {
+ desktopHidden = "hidden";
+ quietActive = "active";
+ } else if (this.state.notify_level === "mention") {
+ mentionActive = "active";
+ } else if (this.state.notify_level === "none") {
+ noneActive = "active";
+ } else {
+ allActive = "active";
+ }
+
+ var self = this;
+ return (
+ <div className="modal fade" id="channel_notifications" 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">Close</span>
+ </button>
+ <h4 className="modal-title">{"Notification Preferences for " + this.state.title}</h4>
+ </div>
+ <div className="modal-body">
+ <div className={desktopHidden}>
+ <span>Desktop Notifications</span>
+ <br/>
+ <div className="btn-group" data-toggle="buttons-radio">
+ <button className={"btn btn-default "+allActive} onClick={function(){self.handleRadioClick("all")}}>Any activity (default)</button>
+ <button className={"btn btn-default "+mentionActive} onClick={function(){self.handleRadioClick("mention")}}>Mentions of my name</button>
+ <button className={"btn btn-default "+noneActive} onClick={function(){self.handleRadioClick("none")}}>Nothing</button>
+ </div>
+ <br/>
+ <br/>
+ </div>
+ <span>Quiet Mode</span>
+ <br/>
+ <div className="btn-group" data-toggle="buttons-checkbox">
+ <button className={"btn btn-default "+quietActive} onClick={this.handleQuietToggle}>Quiet Mode</button>
+ </div>
+ { server_error }
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-primary" onClick={this.handleUpdate}>Done</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ );
+ }
+});
diff --git a/web/react/components/command_list.jsx b/web/react/components/command_list.jsx
new file mode 100644
index 000000000..023f5f760
--- /dev/null
+++ b/web/react/components/command_list.jsx
@@ -0,0 +1,67 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var client = require('../utils/client.jsx');
+
+module.exports = React.createClass({
+ getInitialState: function() {
+ return { suggestions: [ ], cmd: "" };
+ },
+ handleClick: function(i) {
+ this.props.addCommand(this.state.suggestions[i].suggestion)
+ this.setState({ suggestions: [ ], cmd: "" });
+ },
+ addFirstCommand: function() {
+ if (this.state.suggestions.length == 0) return;
+ this.handleClick(0);
+ },
+ isEmpty: function() {
+ return this.state.suggestions.length == 0;
+ },
+ getSuggestedCommands: function(cmd) {
+
+ if (cmd == "") {
+ this.setState({ suggestions: [ ], cmd: "" });
+ return;
+ }
+
+ if (cmd.indexOf("/") != 0) {
+ this.setState({ suggestions: [ ], cmd: "" });
+ return;
+ }
+
+ client.executeCommand(
+ this.props.channelId,
+ cmd,
+ true,
+ function(data) {
+ if (data.suggestions.length === 1 && data.suggestions[0].suggestion === cmd) data.suggestions = [];
+ this.setState({ suggestions: data.suggestions, cmd: cmd });
+ }.bind(this),
+ function(err){
+ }.bind(this)
+ );
+ },
+ render: function() {
+ if (this.state.suggestions.length == 0) return (<div/>);
+
+ var suggestions = []
+
+ for (var i = 0; i < this.state.suggestions.length; i++) {
+ if (this.state.suggestions[i].suggestion != this.state.cmd) {
+ suggestions.push(
+ <div key={i} className="command-name" onClick={this.handleClick.bind(this, i)}>
+ <div className="pull-left"><strong>{ this.state.suggestions[i].suggestion }</strong></div>
+ <div className="command-desc pull-right">{ this.state.suggestions[i].description }</div>
+ </div>
+ );
+ }
+ }
+
+ return (
+ <div ref="mentionlist" className="command-box" style={{height:(this.state.suggestions*37)+2}}>
+ { suggestions }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
new file mode 100644
index 000000000..3534c7573
--- /dev/null
+++ b/web/react/components/create_comment.jsx
@@ -0,0 +1,166 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var client = require('../utils/client.jsx');
+var AsyncClient =require('../utils/async_client.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var Textbox = require('./textbox.jsx');
+var MsgTyping = require('./msg_typing.jsx');
+var FileUpload = require('./file_upload.jsx');
+var FilePreview = require('./file_preview.jsx');
+
+var Constants = require('../utils/constants.jsx');
+
+module.exports = React.createClass({
+ lastTime: 0,
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ if (this.state.uploadsInProgress > 0) return;
+
+ if (this.state.submitting) return;
+
+ var post = {}
+ post.filenames = [];
+
+ post.message = this.state.messageText;
+ if (post.message.trim().length === 0 && this.state.previews.length === 0) {
+ return;
+ }
+
+ if (post.message.length > Constants.CHARACTER_LIMIT) {
+ this.setState({ post_error: 'Comment length must be less than '+Constants.CHARACTER_LIMIT+' characters.' });
+ return;
+ }
+
+ post.channel_id = this.props.channelId;
+ post.root_id = this.props.rootId;
+ post.parent_id = this.props.parentId;
+ post.filenames = this.state.previews;
+
+ this.setState({ submitting: true });
+
+ client.createPost(post, ChannelStore.getCurrent(),
+ function(data) {
+ this.setState({ messageText: '', submitting: false, post_error: null });
+ this.clearPreviews();
+ AsyncClient.getPosts(true, this.props.channelId);
+
+ var channel = ChannelStore.get(this.props.channelId);
+ var member = ChannelStore.getMember(this.props.channelId);
+ member.msg_count = channel.total_msg_count;
+ member.last_viewed_at = (new Date).getTime();
+ ChannelStore.setChannelMember(member);
+
+ }.bind(this),
+ function(err) {
+ var state = {}
+ state.server_error = err.message;
+ this.setState(state);
+ if (err.message === "Invalid RootId parameter") {
+ if ($('#post_deleted').length > 0) $('#post_deleted').modal('show');
+ }
+ }.bind(this)
+ );
+ },
+ commentMsgKeyPress: function(e) {
+ if (e.which == 13 && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ this.refs.textbox.getDOMNode().blur();
+ this.handleSubmit(e);
+ }
+
+ var t = new Date().getTime();
+ if ((t - this.lastTime) > 5000) {
+ SocketStore.sendMessage({channel_id: this.props.channelId, action: "typing", props: {"parent_id": this.props.rootId} });
+ this.lastTime = t;
+ }
+ },
+ handleUserInput: function(messageText) {
+ $(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
+ $(".post-right__scroll").perfectScrollbar('update');
+ this.setState({messageText: messageText});
+ },
+ handleFileUpload: function(newPreviews) {
+ $(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
+ $(".post-right__scroll").perfectScrollbar('update');
+ var oldPreviews = this.state.previews;
+ var num = this.state.uploadsInProgress;
+ this.setState({previews: oldPreviews.concat(newPreviews), uploadsInProgress:num-1});
+ },
+ handleUploadError: function(err) {
+ this.setState({ server_error: err });
+ },
+ clearPreviews: function() {
+ this.setState({previews: []});
+ },
+ removePreview: function(filename) {
+ var previews = this.state.previews;
+ for (var i = 0; i < previews.length; i++) {
+ if (previews[i] === filename) {
+ previews.splice(i, 1);
+ break;
+ }
+ }
+ this.setState({previews: previews});
+ },
+ getInitialState: function() {
+ return { messageText: '', uploadsInProgress: 0, previews: [], submitting: false };
+ },
+ setUploads: function(val) {
+ var num = this.state.uploadsInProgress + val;
+ this.setState({uploadsInProgress: num});
+ },
+ render: function() {
+
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+ var post_error = this.state.post_error ? <label className='control-label'>{this.state.post_error}</label> : null;
+
+ var preview = <div/>;
+ if (this.state.previews.length > 0 || this.state.uploadsInProgress > 0) {
+ preview = (
+ <FilePreview
+ files={this.state.previews}
+ onRemove={this.removePreview}
+ uploadsInProgress={this.state.uploadsInProgress} />
+ );
+ }
+ var limit_previews = ""
+ if (this.state.previews.length > 5) {
+ limit_previews = <div className='has-error'><label className='control-label'>{ "Note: While all files will be available, only first five will show thumbnails." }</label></div>
+ }
+ if (this.state.previews.length > 20) {
+ limit_previews = <div className='has-error'><label className='control-label'>{ "Note: Uploads limited to 20 files maximum. Please use additional posts for more files." }</label></div>
+ }
+
+ return (
+ <form onSubmit={this.handleSubmit}>
+ <div className="post-create">
+ <div id={this.props.rootId} className="post-create-body comment-create-body">
+ <Textbox
+ onUserInput={this.handleUserInput}
+ onKeyPress={this.commentMsgKeyPress}
+ messageText={this.state.messageText}
+ createMessage="Create a comment..."
+ initialText=""
+ id="reply_textbox"
+ ref="textbox" />
+ <FileUpload
+ setUploads={this.setUploads}
+ onFileUpload={this.handleFileUpload}
+ onUploadError={this.handleUploadError} />
+ </div>
+ <MsgTyping channelId={this.props.channelId} parentId={this.props.rootId} />
+ <div className={post_error ? 'has-error' : 'post-create-footer'}>
+ <input type="button" className="btn btn-primary comment-btn pull-right" value="Add Comment" onClick={this.handleSubmit} />
+ { post_error }
+ { server_error }
+ { limit_previews }
+ </div>
+ </div>
+ { preview }
+ </form>
+ );
+ }
+});
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
new file mode 100644
index 000000000..191be9bf8
--- /dev/null
+++ b/web/react/components/create_post.jsx
@@ -0,0 +1,273 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var MsgTyping = require('./msg_typing.jsx');
+var Textbox = require('./textbox.jsx');
+var FileUpload = require('./file_upload.jsx');
+var FilePreview = require('./file_preview.jsx');
+var utils = require('../utils/utils.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+module.exports = React.createClass({
+ lastTime: 0,
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ if (this.state.uploadsInProgress > 0) return;
+
+ if (this.state.submitting) return;
+
+ var post = {};
+ post.filenames = [];
+
+ post.message = this.state.messageText;
+
+ var repRegex = new RegExp("<br>", "g");
+ if (post.message.replace(repRegex, " ").trim().length === 0
+ && this.state.previews.length === 0) {
+ return;
+ }
+
+ if (post.message.length > Constants.CHARACTER_LIMIT) {
+ this.setState({ post_error: 'Post length must be less than '+Constants.CHARACTER_LIMIT+' characters.' });
+ return;
+ }
+
+ this.setState({ submitting: true });
+
+ var user_id = UserStore.getCurrentId();
+
+ if (post.message.indexOf("/") == 0) {
+ client.executeCommand(
+ this.state.channel_id,
+ post.message,
+ false,
+ function(data) {
+ PostStore.storeDraft(data.channel_id, user_id, null);
+ this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null });
+
+ if (data.goto_location.length > 0) {
+ window.location.href = data.goto_location;
+ }
+ }.bind(this),
+ function(err){
+ var state = {}
+ state.server_error = err.message;
+ state.submitting = false;
+ this.setState(state);
+ }.bind(this)
+ );
+ } else {
+ post.channel_id = this.state.channel_id;
+ post.filenames = this.state.previews;
+
+ client.createPost(post, ChannelStore.getCurrent(),
+ function(data) {
+ PostStore.storeDraft(data.channel_id, data.user_id, null);
+ this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null });
+ this.resizePostHolder();
+ AsyncClient.getPosts(true);
+
+ var channel = ChannelStore.get(this.state.channel_id);
+ var member = ChannelStore.getMember(this.state.channel_id);
+ member.msg_count = channel.total_msg_count;
+ member.last_viewed_at = (new Date).getTime();
+ ChannelStore.setChannelMember(member);
+
+ }.bind(this),
+ function(err) {
+ var state = {}
+ state.server_error = err.message;
+ state.submitting = false;
+ this.setState(state);
+ }.bind(this)
+ );
+ }
+
+ $(".post-list-holder-by-time").perfectScrollbar('update');
+ },
+ componentDidUpdate: function() {
+ this.resizePostHolder();
+ },
+ postMsgKeyPress: function(e) {
+ if (e.which == 13 && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ this.refs.textbox.getDOMNode().blur();
+ this.handleSubmit(e);
+ }
+
+ var t = new Date().getTime();
+ if ((t - this.lastTime) > 5000) {
+ SocketStore.sendMessage({channel_id: this.state.channel_id, action: "typing", props: {"parent_id": ""}, state: {} });
+ this.lastTime = t;
+ }
+ },
+ handleUserInput: function(messageText) {
+ this.resizePostHolder();
+ this.setState({messageText: messageText});
+ var draft = PostStore.getCurrentDraft();
+ if (!draft) {
+ draft = {}
+ draft['previews'] = [];
+ draft['uploadsInProgress'] = 0;
+ }
+ draft['message'] = messageText;
+ PostStore.storeCurrentDraft(draft);
+ },
+ resizePostHolder: function() {
+ var height = $(window).height() - $(this.refs.topDiv.getDOMNode()).height() - $('#error_bar').outerHeight() - 50;
+ $(".post-list-holder-by-time").css("height", height + "px");
+ $(window).trigger('resize');
+ },
+ handleFileUpload: function(newPreviews, channel_id) {
+ var draft = PostStore.getDraft(channel_id, UserStore.getCurrentId());
+ if (!draft) {
+ draft = {}
+ draft['message'] = '';
+ draft['uploadsInProgress'] = 0;
+ draft['previews'] = [];
+ }
+
+ if (channel_id === this.state.channel_id) {
+ var num = this.state.uploadsInProgress;
+ var oldPreviews = this.state.previews;
+ var previews = oldPreviews.concat(newPreviews);
+
+ draft['previews'] = previews;
+ draft['uploadsInProgress'] = num-1;
+ PostStore.storeCurrentDraft(draft);
+
+ this.setState({previews: previews, uploadsInProgress:num-1});
+ } else {
+ draft['previews'] = draft['previews'].concat(newPreviews);
+ draft['uploadsInProgress'] = draft['uploadsInProgress'] > 0 ? draft['uploadsInProgress'] - 1 : 0;
+ PostStore.storeDraft(channel_id, UserStore.getCurrentId(), draft);
+ }
+ },
+ handleUploadError: function(err) {
+ this.setState({ server_error: err });
+ },
+ removePreview: function(filename) {
+ var previews = this.state.previews;
+ for (var i = 0; i < previews.length; i++) {
+ if (previews[i] === filename) {
+ previews.splice(i, 1);
+ break;
+ }
+ }
+ var draft = PostStore.getCurrentDraft();
+ if (!draft) {
+ draft = {}
+ draft['message'] = '';
+ draft['uploadsInProgress'] = 0;
+ }
+ draft['previews'] = previews;
+ PostStore.storeCurrentDraft(draft);
+ this.setState({previews: previews});
+ },
+ componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+ this.resizePostHolder();
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var channel_id = ChannelStore.getCurrentId();
+ if (this.state.channel_id != channel_id) {
+ var draft = PostStore.getCurrentDraft();
+ var previews = [];
+ var messageText = '';
+ var uploadsInProgress = 0;
+ if (draft) {
+ previews = draft['previews'];
+ messageText = draft['message'];
+ uploadsInProgress = draft['uploadsInProgress'];
+ }
+ this.setState({ channel_id: channel_id, messageText: messageText, initialText: messageText, submitting: false, post_error: null, previews: previews, uploadsInProgress: uploadsInProgress });
+ }
+ },
+ getInitialState: function() {
+ PostStore.clearDraftUploads();
+
+ var draft = PostStore.getCurrentDraft();
+ var previews = [];
+ var messageText = '';
+ if (draft) {
+ previews = draft['previews'];
+ messageText = draft['message'];
+ }
+ return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText };
+ },
+ setUploads: function(val) {
+ var num = this.state.uploadsInProgress + val;
+ var draft = PostStore.getCurrentDraft();
+ if (!draft) {
+ draft = {}
+ draft['message'] = '';
+ draft['previews'] = [];
+ }
+ draft['uploadsInProgress'] = num;
+ PostStore.storeCurrentDraft(draft);
+ this.setState({uploadsInProgress: num});
+ },
+ render: function() {
+
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+ var post_error = this.state.post_error ? <label className='control-label'>{this.state.post_error}</label> : null;
+
+ var preview = <div/>;
+ if (this.state.previews.length > 0 || this.state.uploadsInProgress > 0) {
+ preview = (
+ <FilePreview
+ files={this.state.previews}
+ onRemove={this.removePreview}
+ uploadsInProgress={this.state.uploadsInProgress} />
+ );
+ }
+ var limit_previews = ""
+ if (this.state.previews.length > 5) {
+ limit_previews = <div className='has-error'><label className='control-label'>{ "Note: While all files will be available, only first five will show thumbnails." }</label></div>
+ }
+ if (this.state.previews.length > 20) {
+ limit_previews = <div className='has-error'><label className='control-label'>{ "Note: Uploads limited to 20 files maximum. Please use additional posts for more files." }</label></div>
+ }
+
+ return (
+ <form id="create_post" ref="topDiv" role="form" onSubmit={this.handleSubmit}>
+ <div className="post-create">
+ <div className="post-create-body">
+ <Textbox
+ onUserInput={this.handleUserInput}
+ onKeyPress={this.postMsgKeyPress}
+ messageText={this.state.messageText}
+ createMessage="Create a post..."
+ channelId={this.state.channel_id}
+ id="post_textbox"
+ ref="textbox" />
+ <FileUpload
+ setUploads={this.setUploads}
+ onFileUpload={this.handleFileUpload}
+ onUploadError={this.handleUploadError} />
+ </div>
+ <div className={post_error ? 'post-create-footer has-error' : 'post-create-footer'}>
+ { post_error }
+ { server_error }
+ { limit_previews }
+ { preview }
+ <MsgTyping channelId={this.state.channel_id} parentId=""/>
+ </div>
+ </div>
+ </form>
+ );
+ }
+});
diff --git a/web/react/components/delete_channel_modal.jsx b/web/react/components/delete_channel_modal.jsx
new file mode 100644
index 000000000..a8c690789
--- /dev/null
+++ b/web/react/components/delete_channel_modal.jsx
@@ -0,0 +1,58 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client =require('../utils/client.jsx');
+var AsyncClient =require('../utils/async_client.jsx');
+var ChannelStore =require('../stores/channel_store.jsx')
+
+module.exports = React.createClass({
+ handleDelete: function(e) {
+ if (this.state.channel_id.length != 26) return;
+
+ Client.deleteChannel(this.state.channel_id,
+ function(data) {
+ AsyncClient.getChannels(true);
+ window.location.href = '/channels/town-square';
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "handleDelete");
+ }.bind(this)
+ );
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = $(e.relatedTarget);
+ self.setState({ title: button.attr('data-title'), channel_id: button.attr('data-channelid') });
+ });
+ },
+ getInitialState: function() {
+ return { title: "", channel_id: "" };
+ },
+ render: function() {
+
+ var channelType = ChannelStore.getCurrent() && ChannelStore.getCurrent().type === 'P' ? "private group" : "channel"
+
+ return (
+ <div className="modal fade" ref="modal" id="delete_channel" 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" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title">Confirm DELETE Channel</h4>
+ </div>
+ <div className="modal-body">
+ <p>
+ Are you sure you wish to delete the {this.state.title} {channelType}?
+ </p>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button type="button" className="btn btn-danger" data-dismiss="modal" onClick={this.handleDelete}>Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx
new file mode 100644
index 000000000..c88b548d1
--- /dev/null
+++ b/web/react/components/delete_post_modal.jsx
@@ -0,0 +1,108 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var utils = require('../utils/utils.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+module.exports = React.createClass({
+ handleDelete: function(e) {
+ Client.deletePost(this.state.channel_id, this.state.post_id,
+ function(data) {
+ var selected_list = this.state.selectedList;
+ if (selected_list && selected_list.order && selected_list.order.length > 0) {
+ var selected_post = selected_list.posts[selected_list.order[0]];
+ if ((selected_post.id === this.state.post_id && this.state.title === "Post") || selected_post.root_id === this.state.post_id) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ } else if (selected_post.id === this.state.post_id && this.state.title === "Comment") {
+ if (selected_post.root_id && selected_post.root_id.length > 0 && selected_list.posts[selected_post.root_id]) {
+ selected_list.order = [selected_post.root_id];
+ delete selected_list.posts[selected_post.id];
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ post_list: selected_list
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+ }
+ }
+ }
+ AsyncClient.getPosts(true, this.state.channel_id);
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "deletePost");
+ }.bind(this)
+ );
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var newState = {};
+ if(sessionStorage.getItem('edit_state_transfer')) {
+ newState = JSON.parse(sessionStorage.getItem('edit_state_transfer'));
+ sessionStorage.removeItem('edit_state_transfer');
+ } else {
+ var button = e.relatedTarget;
+ newState = { title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments') };
+ }
+ self.setState(newState)
+ });
+ PostStore.addSelectedPostChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ PostStore.removeSelectedPostChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newList = PostStore.getSelectedPost();
+ if (!utils.areStatesEqual(this.state.selectedList, newList)) {
+ this.setState({ selectedList: newList });
+ }
+ },
+ getInitialState: function() {
+ return { title: "", post_id: "", channel_id: "", selectedList: PostStore.getSelectedPost(), comments: 0 };
+ },
+ render: function() {
+ var error = this.state.error ? <div className='form-group has-error'><label className='control-label'>{ this.state.error }</label></div> : null;
+
+ return (
+ <div className="modal fade" id="delete_post" ref="modal" role="dialog" aria-hidden="true">
+ <div className="modal-dialog modal-push-down">
+ <div className="modal-content">
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title">Confirm {this.state.title} Delete</h4>
+ </div>
+ <div className="modal-body">
+ Are you sure you want to delete the {this.state.title.toLowerCase()}?
+ <br/>
+ <br/>
+ { this.state.comments > 0 ?
+ "This post has " + this.state.comments + " comment(s) on it."
+ : "" }
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button type="button" className="btn btn-danger" data-dismiss="modal" onClick={this.handleDelete}>Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx
new file mode 100644
index 000000000..f1f4eca40
--- /dev/null
+++ b/web/react/components/edit_channel_modal.jsx
@@ -0,0 +1,57 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+
+module.exports = React.createClass({
+ handleEdit: function(e) {
+ var data = {}
+ data["channel_id"] = this.state.channel_id;
+ if (data["channel_id"].length !== 26) return;
+ data["channel_description"] = this.state.description.trim();
+
+ Client.updateChannelDesc(data,
+ function(data) {
+ AsyncClient.getChannels(true);
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "updateChannelDesc");
+ }.bind(this)
+ );
+ },
+ handleUserInput: function(e) {
+ this.setState({ description: e.target.value });
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid') });
+ });
+ },
+ getInitialState: function() {
+ return { description: "", title: "", channel_id: "" };
+ },
+ render: function() {
+ return (
+ <div className="modal fade" ref="modal" id="edit_channel" 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" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title">Edit {this.state.title} Description</h4>
+ </div>
+ <div className="modal-body">
+ <textarea className="form-control" rows="6" ref="channelDesc" maxLength="1024" value={this.state.description} onChange={this.handleUserInput}></textarea>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button type="button" className="btn btn-primary" data-dismiss="modal" onClick={this.handleEdit}>Save</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx
new file mode 100644
index 000000000..24c2d7322
--- /dev/null
+++ b/web/react/components/edit_post_modal.jsx
@@ -0,0 +1,100 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var Textbox = require('./textbox.jsx');
+
+module.exports = React.createClass({
+ handleEdit: function(e) {
+ var updatedPost = {};
+ updatedPost.message = this.state.editText.trim();
+
+ if (updatedPost.message.length === 0) {
+ var tempState = this.state;
+ delete tempState.editText;
+ sessionStorage.setItem('edit_state_transfer', JSON.stringify(tempState));
+ $("#edit_post").modal('hide');
+ $("#delete_post").modal('show');
+ return;
+ }
+
+ updatedPost.id = this.state.post_id
+ updatedPost.channel_id = this.state.channel_id
+
+ Client.updatePost(updatedPost,
+ function(data) {
+ AsyncClient.getPosts(true, this.state.channel_id);
+ window.scrollTo(0, 0);
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "updatePost");
+ }.bind(this)
+ );
+
+ $("#edit_post").modal('hide');
+ },
+ handleEditInput: function(editText) {
+ this.setState({ editText: editText });
+ },
+ handleEditKeyPress: function(e) {
+ if (e.which == 13 && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ this.refs.editbox.getDOMNode().blur();
+ this.handleEdit(e);
+ }
+ },
+ handleUserInput: function(e) {
+ this.setState({ editText: e.target.value });
+ },
+ componentDidMount: function() {
+ var self = this;
+
+ $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
+ self.setState({ editText: "", title: "", channel_id: "", post_id: "", comments: 0 });
+ });
+
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments') });
+ });
+
+ $(this.refs.modal.getDOMNode()).on('shown.bs.modal', function(e) {
+ self.refs.editbox.resize();
+ });
+ },
+ getInitialState: function() {
+ return { editText: "", title: "", post_id: "", channel_id: "", comments: 0 };
+ },
+ render: function() {
+ var error = this.state.error ? <div className='form-group has-error'><label className='control-label'>{ this.state.error }</label></div> : null;
+
+ return (
+ <div className="modal fade edit-modal" ref="modal" id="edit_post" role="dialog" aria-hidden="true">
+ <div className="modal-dialog modal-push-down">
+ <div className="modal-content">
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close" onClick={this.handleEditClose}><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title">Edit {this.state.title}</h4>
+ </div>
+ <div className="edit-modal-body modal-body">
+ <Textbox
+ onUserInput={this.handleEditInput}
+ onKeyPress={this.handleEditKeyPress}
+ messageText={this.state.editText}
+ createMessage="Edit the post..."
+ id="edit_textbox"
+ ref="editbox"
+ />
+ { error }
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button>
+ <button type="button" className="btn btn-primary" onClick={this.handleEdit}>Save</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/email_verify.jsx b/web/react/components/email_verify.jsx
new file mode 100644
index 000000000..168608274
--- /dev/null
+++ b/web/react/components/email_verify.jsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ handleResend: function() {
+ window.location.href = window.location.href + "&resend=true"
+ },
+ render: function() {
+ var title = "";
+ var body = "";
+ var resend = "";
+ if (this.props.isVerified === "true") {
+ title = config.SiteName + " Email Verified";
+ body = <p>Your email has been verified! Click <a href="/">here</a> to log in.</p>;
+ } else {
+ title = config.SiteName + " Email Not Verified";
+ body = <p>Please verify your email address. Check your inbox for an email.</p>;
+ resend = <button onClick={this.handleResend} className="btn btn-primary">Resend Email</button>
+ }
+
+ return (
+ <div className="col-sm-offset-4 col-sm-4">
+ <div className="panel panel-default">
+ <div className="panel-heading">
+ <h3 className="panel-title">{ title }</h3>
+ </div>
+ <div className="panel-body">
+ { body }
+ { resend }
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx
new file mode 100644
index 000000000..f23dc060e
--- /dev/null
+++ b/web/react/components/error_bar.jsx
@@ -0,0 +1,69 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ErrorStore = require('../stores/error_store.jsx');
+var utils = require('../utils/utils.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+function getStateFromStores() {
+ var error = ErrorStore.getLastError();
+ if (error) {
+ return { message: error.message };
+ } else {
+ return { message: null };
+ }
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ErrorStore.addChangeListener(this._onChange);
+ $('body').css('padding-top', $('#error_bar').outerHeight());
+ $(window).resize(function(){
+ $('body').css('padding-top', $('#error_bar').outerHeight());
+ });
+ },
+ componentWillUnmount: function() {
+ ErrorStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ if (newState.message) {
+ var self = this;
+ setTimeout(function(){self.handleClose();}, 10000);
+ }
+ this.setState(newState);
+ }
+ },
+ handleClose: function(e) {
+ if (e) e.preventDefault();
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_ERROR,
+ err: null
+ });
+ $('body').css('padding-top', '0');
+ },
+ getInitialState: function() {
+ var state = getStateFromStores();
+ if (state.message) {
+ var self = this;
+ setTimeout(function(){self.handleClose();}, 10000);
+ }
+ return state;
+ },
+ render: function() {
+ var message = this.state.message;
+ if (message) {
+ return (
+ <div className="error-bar">
+ <span className="error-text">{message}</span>
+ <a href="#" className="error-close pull-right" onClick={this.handleClose}>×</a>
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx
new file mode 100644
index 000000000..99327c22f
--- /dev/null
+++ b/web/react/components/file_preview.jsx
@@ -0,0 +1,54 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+var Constants = require('../utils/constants.jsx');
+
+module.exports = React.createClass({
+ handleRemove: function(e) {
+ var previewDiv = e.target.parentNode.parentNode;
+ this.props.onRemove(previewDiv.dataset.filename);
+ },
+ render: function() {
+ var previews = [];
+ this.props.files.forEach(function(filename) {
+
+ var filenameSplit = filename.split('.');
+ var ext = filenameSplit[filenameSplit.length-1];
+ var type = utils.getFileType(ext);
+
+ if (type === "image") {
+ previews.push(
+ <div key={filename} className="preview-div" data-filename={filename}>
+ <img className="preview-img" src={filename}/>
+ <a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
+ </div>
+ );
+ } else {
+ previews.push(
+ <div key={filename} className="preview-div custom-file" data-filename={filename}>
+ <div className={"file-icon "+utils.getIconClassName(type)}/>
+ <a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
+ </div>
+ );
+ }
+ }.bind(this));
+
+ for (var i = 0; i < this.props.uploadsInProgress; i++) {
+ previews.push(
+ <div className="preview-div">
+ <img className="spinner" src="/static/images/load.gif"/>
+ </div>
+ );
+ }
+
+ return (
+ <div className="preview-container">
+ {previews}
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx
new file mode 100644
index 000000000..c03a61c63
--- /dev/null
+++ b/web/react/components/file_upload.jsx
@@ -0,0 +1,129 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var client = require('../utils/client.jsx');
+var Constants = require('../utils/constants.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+
+module.exports = React.createClass({
+ handleChange: function() {
+ var element = $(this.refs.fileInput.getDOMNode());
+ var files = element.prop('files');
+
+ this.props.onUploadError(null);
+
+ //This looks redundant, but must be done this way due to
+ //setState being an asynchronous call
+ var numFiles = 0;
+ for(var i = 0; i < files.length && i <= 20 ; i++) {
+ if (files[i].size <= Constants.MAX_FILE_SIZE) {
+ numFiles++;
+ }
+ }
+
+ this.props.setUploads(numFiles);
+
+ for (var i = 0; i < files.length && i <= 20; i++) {
+ if (files[i].size > Constants.MAX_FILE_SIZE) {
+ this.props.onUploadError("Files must be no more than " + Constants.MAX_FILE_SIZE/1000000 + " MB");
+ continue;
+ }
+
+ var channel_id = ChannelStore.getCurrentId();
+
+ // Prepare data to be uploaded.
+ formData = new FormData();
+ formData.append('channel_id', channel_id);
+ formData.append('files', files[i], files[i].name);
+
+ client.uploadFile(formData,
+ function(data) {
+ parsedData = $.parseJSON(data);
+ this.props.onFileUpload(parsedData['filenames'], channel_id);
+ }.bind(this),
+ function(err) {
+ this.props.setUploads(-1);
+ this.props.onUploadError(err);
+ }.bind(this)
+ );
+ }
+
+ // clear file input for all modern browsers
+ try{
+ element[0].value = '';
+ if(element.value){
+ element[0].type = "text";
+ element[0].type = "file";
+ }
+ }catch(e){}
+ },
+ componentDidMount: function() {
+ var inputDiv = this.refs.input.getDOMNode();
+ var self = this;
+
+ document.addEventListener("paste", function(e) {
+ var textarea = $(inputDiv.parentNode.parentNode).find('.custom-textarea')[0];
+
+ if (textarea != e.target && !$.contains(textarea,e.target)) {
+ return;
+ }
+
+ self.props.onUploadError(null);
+
+ //This looks redundant, but must be done this way due to
+ //setState being an asynchronous call
+ var items = e.clipboardData.items;
+ var numItems = 0;
+ if (items) {
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].type.indexOf("image") !== -1) {
+
+ ext = items[i].type.split("/")[1].toLowerCase();
+ ext = ext == 'jpeg' ? 'jpg' : ext;
+
+ if (Constants.IMAGE_TYPES.indexOf(ext) < 0) return;
+
+ numItems++
+ }
+ }
+
+ self.props.setUploads(numItems);
+
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].type.indexOf("image") !== -1) {
+ var file = items[i].getAsFile();
+
+ ext = items[i].type.split("/")[1].toLowerCase();
+ ext = ext == 'jpeg' ? 'jpg' : ext;
+
+ if (Constants.IMAGE_TYPES.indexOf(ext) < 0) return;
+
+ var channel_id = ChannelStore.getCurrentId();
+
+ formData = new FormData();
+ formData.append('channel_id', channel_id);
+ var d = new Date();
+ var hour = d.getHours() < 10 ? "0" + d.getHours() : String(d.getHours());
+ var min = d.getMinutes() < 10 ? "0" + d.getMinutes() : String(d.getMinutes());
+ formData.append('files', file, "Image Pasted at "+d.getFullYear()+"-"+d.getMonth()+"-"+d.getDate()+" "+hour+"-"+min+"." + ext);
+
+ client.uploadFile(formData,
+ function(data) {
+ parsedData = $.parseJSON(data);
+ self.props.onFileUpload(parsedData['filenames'], channel_id);
+ }.bind(this),
+ function(err) {
+ self.props.onUploadError(err);
+ }.bind(this)
+ );
+ }
+ }
+ }
+ });
+ },
+ render: function() {
+ return (
+ <span ref="input" className="btn btn-file"><span><i className="glyphicon glyphicon-paperclip"></i></span><input ref="fileInput" type="file" onChange={this.handleChange} multiple/></span>
+ );
+ }
+});
diff --git a/web/react/components/find_team.jsx b/web/react/components/find_team.jsx
new file mode 100644
index 000000000..329592a73
--- /dev/null
+++ b/web/react/components/find_team.jsx
@@ -0,0 +1,72 @@
+// 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');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ var state = { };
+
+ var email = this.refs.email.getDOMNode().value.trim().toLowerCase();
+ if (!email || !utils.isEmail(email)) {
+ state.email_error = "Please enter a valid email address";
+ this.setState(state);
+ return;
+ }
+ else {
+ state.email_error = "";
+ }
+
+ client.findTeamsSendEmail(email,
+ function(data) {
+ state.sent = true;
+ this.setState(state);
+ }.bind(this),
+ function(err) {
+ state.email_error = err.message;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ var email_error = this.state.email_error ? <label className='control-label'>{ this.state.email_error }</label> : null;
+
+ var divStyle = {
+ "marginTop": "50px",
+ }
+
+ if (this.state.sent) {
+ return (
+ <div>
+ <h4>{"Find Your " + utils.toTitleCase(strings.Team)}</h4>
+ <p>{"An email was sent with links to any " + strings.TeamPlural}</p>
+ </div>
+ );
+ }
+
+ return (
+ <div>
+ <h4>Find Your Team</h4>
+ <form onSubmit={this.handleSubmit}>
+ <p>{"An email will be sent to this address with links to any " + strings.TeamPlural}</p>
+ <div className="form-group">
+ <label className='control-label'>Email</label>
+ <div className={ email_error ? "form-group has-error" : "form-group" }>
+ <input type="text" ref="email" className="form-control" placeholder="you@domain.com" maxLength="128" />
+ { email_error }
+ </div>
+ </div>
+ <button className="btn btn-md btn-primary" type="submit">Send</button>
+ </form>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx
new file mode 100644
index 000000000..334591ee3
--- /dev/null
+++ b/web/react/components/get_link_modal.jsx
@@ -0,0 +1,55 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var ZeroClipboardMixin = require('react-zeroclipboard-mixin');
+
+ZeroClipboardMixin.ZeroClipboard.config({
+ swfPath: '../../static/flash/ZeroClipboard.swf'
+});
+
+module.exports = React.createClass({
+ zeroclipboardElementsSelector: '[data-copy-btn]',
+ mixins: [ ZeroClipboardMixin ],
+ componentDidMount: function() {
+ var self = this;
+ if(this.refs.modal) {
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({title: $(button).attr('data-title'), value: $(button).attr('data-value') });
+ });
+ }
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var currentUser = UserStore.getCurrentUser()
+
+ if (currentUser != null) {
+ return (
+ <div className="modal fade" ref="modal" id="get_link" 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" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" id="myModalLabel">{this.state.title} Link</h4>
+ </div>
+ <div className="modal-body">
+ <p>{"The link below is used for open " + strings.TeamPlural + " or if you allowed your " + strings.Team + " members to sign up using their " + strings.Company + " email addresses."}
+ </p>
+ <textarea className="form-control" readOnly="true" value={this.state.value}></textarea>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button data-copy-btn type="button" className="btn btn-primary" data-clipboard-text={this.state.value}>Copy Link</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx
new file mode 100644
index 000000000..1d2bbed84
--- /dev/null
+++ b/web/react/components/invite_member_modal.jsx
@@ -0,0 +1,179 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ var invite_ids = this.state.invite_ids;
+ var count = invite_ids.length;
+ var invites = [];
+ var email_errors = this.state.email_errors;
+ var first_name_errors = this.state.first_name_errors;
+ var last_name_errors = this.state.last_name_errors;
+ var valid = true;
+
+ for (var i = 0; i < count; i++) {
+ var index = invite_ids[i];
+ var invite = {};
+ invite.email = this.refs["email"+index].getDOMNode().value.trim();
+ if (!invite.email || !utils.isEmail(invite.email)) {
+ email_errors[index] = "Please enter a valid email address";
+ valid = false;
+ } else {
+ email_errors[index] = "";
+ }
+
+ if (config.AllowInviteNames) {
+ invite.first_name = this.refs["first_name"+index].getDOMNode().value.trim();
+ if (!invite.first_name ) {
+ first_name_errors[index] = "This is a required field";
+ valid = false;
+ } else {
+ first_name_errors[index] = "";
+ }
+
+ invite.last_name = this.refs["last_name"+index].getDOMNode().value.trim();
+ if (!invite.last_name ) {
+ last_name_errors[index] = "This is a required field";
+ valid = false;
+ } else {
+ last_name_errors[index] = "";
+ }
+ }
+
+ invites.push(invite);
+ }
+
+ this.setState({ email_errors: email_errors, first_name_errors: first_name_errors, last_name_errors: last_name_errors });
+
+ if (!valid || invites.length === 0) return;
+
+ var data = {}
+ data["invites"] = invites;
+
+ Client.inviteMembers(data,
+ function() {
+ $(this.refs.modal.getDOMNode()).modal('hide');
+ for (var i = 0; i < invite_ids.length; i++) {
+ var index = invite_ids[i];
+ this.refs["email"+index].getDOMNode().value = "";
+ if (config.AllowInviteNames) {
+ this.refs["first_name"+index].getDOMNode().value = "";
+ this.refs["last_name"+index].getDOMNode().value = "";
+ }
+ }
+ this.setState({
+ invite_ids: [0],
+ id_count: 0,
+ email_errors: {},
+ first_name_errors: {},
+ last_name_errors: {}
+ });
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err });
+ }.bind(this)
+ );
+
+ },
+ componentDidUpdate: function() {
+ $(this.refs.modalBody.getDOMNode()).css('max-height', $(window).height() - 200);
+ $(this.refs.modalBody.getDOMNode()).css('overflow-y', 'scroll');
+ },
+ addInviteFields: function() {
+ var count = this.state.id_count + 1;
+ var invite_ids = this.state.invite_ids;
+ invite_ids.push(count);
+ this.setState({ invite_ids: invite_ids, id_count: count });
+ },
+ removeInviteFields: function(index) {
+ var invite_ids = this.state.invite_ids;
+ var i = invite_ids.indexOf(index);
+ if (index > -1) invite_ids.splice(i, 1);
+ this.setState({ invite_ids: invite_ids });
+ },
+ getInitialState: function() {
+ return {
+ invite_ids: [0],
+ id_count: 0,
+ email_errors: {},
+ first_name_errors: {},
+ last_name_errors: {}
+ };
+ },
+ render: function() {
+ var currentUser = UserStore.getCurrentUser()
+
+ if (currentUser != null) {
+ var invite_sections = [];
+ var invite_ids = this.state.invite_ids;
+ var self = this;
+ for (var i = 0; i < invite_ids.length; i++) {
+ var index = invite_ids[i];
+ var email_error = this.state.email_errors[index] ? <label className='control-label'>{ this.state.email_errors[index] }</label> : null;
+ var first_name_error = this.state.first_name_errors[index] ? <label className='control-label'>{ this.state.first_name_errors[index] }</label> : null;
+ var last_name_error = this.state.last_name_errors[index] ? <label className='control-label'>{ this.state.last_name_errors[index] }</label> : null;
+
+ invite_sections[index] = (
+ <div key={"key" + index}>
+ { i ?
+ <div>
+ <button type="button" className="btn remove__member" onClick={function(){self.removeInviteFields(index);}}>×</button>
+ </div>
+ : ""}
+ <div className={ email_error ? "form-group invite has-error" : "form-group invite" }>
+ <input onKeyUp={this.displayNameKeyUp} type="text" ref={"email"+index} className="form-control" placeholder="email@domain.com" maxLength="64" />
+ { email_error }
+ </div>
+ { config.AllowInviteNames ?
+ <div className={ first_name_error ? "form-group invite has-error" : "form-group invite" }>
+ <input type="text" className="form-control" ref={"first_name"+index} placeholder="First name" maxLength="64" />
+ { first_name_error }
+ </div>
+ : "" }
+ { config.AllowInviteNames ?
+ <div className={ last_name_error ? "form-group invite has-error" : "form-group invite" }>
+ <input type="text" className="form-control" ref={"last_name"+index} placeholder="Last name" maxLength="64" />
+ { last_name_error }
+ </div>
+ : "" }
+ </div>
+ );
+ }
+
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ return (
+ <div className="modal fade" ref="modal" id="invite_member" 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" aria-label="Close" data-reactid=".5.0.0.0.0"><span aria-hidden="true" data-reactid=".5.0.0.0.0.0">×</span></button>
+ <h4 className="modal-title" id="myModalLabel">Invite New Member</h4>
+ </div>
+ <div ref="modalBody" className="modal-body">
+ <form role="form">
+ { invite_sections }
+ </form>
+ { server_error }
+ <button type="button" className="btn btn-default" onClick={this.addInviteFields}>Add another</button>
+ <br/>
+ <br/>
+ <label className='control-label'>People invited automatically join Town Square channel.</label>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Send Invitations</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx
new file mode 100644
index 000000000..8d82a4b62
--- /dev/null
+++ b/web/react/components/login.jsx
@@ -0,0 +1,197 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+
+
+var FindTeamDomain = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ var state = { }
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.server_error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ state.server_error = "";
+ this.setState(state);
+
+ client.findTeamByDomain(domain,
+ function(data) {
+ console.log(data);
+ if (data) {
+ window.location.href = window.location.protocol + "//" + domain + "." + utils.getDomainWithOutSub();
+ }
+ else {
+ this.state.server_error = "We couldn't find your " + strings.TeamPlural + ".";
+ this.setState(this.state);
+ }
+ }.bind(this),
+ function(err) {
+ this.state.server_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <label className="control-label">{this.state.server_error}</label> : null;
+
+ return (
+ <div className="signup-team__container">
+ <div>
+ <span className="signup-team__name">{ config.SiteName }</span>
+ <br/>
+ <span className="signup-team__subdomain">Enter your {strings.TeamPlural} domain.</span>
+ <br/>
+ <br/>
+ </div>
+ <form onSubmit={this.handleSubmit}>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ { server_error }
+ <input type="text" className="form-control" name="domain" ref="domain" placeholder="teamdomain" />
+ </div>
+ <div className="form-group">
+ <button type="submit" className="btn btn-primary">Continue</button>
+ </div>
+ <div>
+ <span>Don't remember your {strings.TeamPlural} domain? <a href="/find_team">Find it here</a></span>
+ </div>
+ <br/>
+ <br/>
+ <br/>
+ <br/>
+ <br/>
+ <br/>
+ <div>
+ <span>{"Want to create your own " + strings.Team + "?"} <a href="/" className="signup-team-login">Sign up now</a></span>
+ </div>
+ </form>
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ var state = { }
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.server_error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ var email = this.refs.email.getDOMNode().value.trim();
+ if (!email) {
+ state.server_error = "An email is required"
+ this.setState(state);
+ return;
+ }
+
+ var password = this.refs.password.getDOMNode().value.trim();
+ if (!password) {
+ state.server_error = "A password is required"
+ this.setState(state);
+ return;
+ }
+
+ state.server_error = "";
+ this.setState(state);
+
+ client.loginByEmail(domain, email, password,
+ function(data) {
+ UserStore.setLastDomain(domain);
+ UserStore.setLastEmail(email);
+ UserStore.setCurrentUser(data);
+
+ var redirect = utils.getUrlParameter("redirect");
+ if (redirect) {
+ window.location.href = decodeURI(redirect);
+ } else {
+ window.location.href = '/channels/town-square';
+ }
+
+ }.bind(this),
+ function(err) {
+ if (err.message == "Login failed because email address has not been verified") {
+ window.location.href = '/verify?domain=' + encodeURIComponent(domain) + '&email=' + encodeURIComponent(email);
+ return;
+ }
+ state.server_error = err.message;
+ this.valid = false;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <label className="control-label">{this.state.server_error}</label> : null;
+ var priorEmail = UserStore.getLastEmail() !== "undefined" ? UserStore.getLastEmail() : ""
+
+ var emailParam = utils.getUrlParameter("email");
+ if (emailParam) {
+ priorEmail = decodeURIComponent(emailParam);
+ }
+
+ var subDomainClass = "form-control hidden";
+ var subDomain = utils.getSubDomain();
+
+ if (utils.isTestDomain()) {
+ subDomainClass = "form-control";
+ subDomain = UserStore.getLastDomain();
+ } else if (subDomain == "") {
+ return (<FindTeamDomain />);
+ }
+
+ return (
+ <div className="signup-team__container">
+ <div>
+ <span className="signup-team__name">{ subDomain }</span>
+ <br/>
+ <span className="signup-team__subdomain">{ utils.getDomainWithOutSub() }</span>
+ <br/>
+ <br/>
+ </div>
+ <form onSubmit={this.handleSubmit}>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ { server_error }
+ <input type="text" className={subDomainClass} name="domain" defaultValue={subDomain} ref="domain" placeholder="Domain" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="email" className="form-control" name="email" defaultValue={priorEmail} ref="email" placeholder="Email" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="password" className="form-control" name="password" ref="password" placeholder="Password" />
+ </div>
+ <div className="form-group">
+ <button type="submit" className="btn btn-primary">Sign in</button>
+ </div>
+ <div className="form-group form-group--small">
+ <span><a href="/find_team">{"Find other " + strings.TeamPlural}</a></span>
+ </div>
+ <div className="form-group">
+ <a href="/reset_password">I forgot my password</a>
+ </div>
+ <div className="external-link">
+ <span>{"Want to create your own " + strings.Team + "?"} <a href={config.HomeLink} className="signup-team-login">Sign up now</a></span>
+ </div>
+ </form>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/member_list.jsx b/web/react/components/member_list.jsx
new file mode 100644
index 000000000..a37392f96
--- /dev/null
+++ b/web/react/components/member_list.jsx
@@ -0,0 +1,34 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var MemberListItem = require('./member_list_item.jsx');
+
+module.exports = React.createClass({
+ render: function() {
+ var members = [];
+
+ if (this.props.memberList != null) {
+ members = this.props.memberList;
+ }
+
+ var message = "";
+ if (members.length === 0)
+ message = <span>No users to add or manage.</span>;
+
+ return (
+ <div className="member-list-holder">
+ {members.map(function(member) {
+ return <MemberListItem
+ key={member.id}
+ member={member}
+ isAdmin={this.props.isAdmin}
+ handleInvite={this.props.handleInvite}
+ handleRemove={this.props.handleRemove}
+ handleMakeAdmin={this.props.handleMakeAdmin}
+ />;
+ }, this)}
+ {message}
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx
new file mode 100644
index 000000000..f0bbff8bd
--- /dev/null
+++ b/web/react/components/member_list_item.jsx
@@ -0,0 +1,67 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+
+module.exports = React.createClass({
+ handleInvite: function() {
+ this.props.handleInvite(this.props.member.id);
+ },
+ handleRemove: function() {
+ this.props.handleRemove(this.props.member.id);
+ },
+ handleMakeAdmin: function() {
+ this.props.handleMakeAdmin(this.props.member.id);
+ },
+ render: function() {
+
+ var member = this.props.member;
+ var isAdmin = this.props.isAdmin;
+ var isMemberAdmin = member.roles.indexOf("admin") > -1;
+
+ if (member.roles === '') {
+ member.roles = 'Member';
+ } else {
+ member.roles = member.roles.charAt(0).toUpperCase() + member.roles.slice(1);
+ }
+
+ var invite;
+ if (member.invited && this.props.handleInvite) {
+ invite = <span className="member-role">Added</span>;
+ } else if (this.props.handleInvite) {
+ invite = <a onClick={this.handleInvite} className="btn btn-sm btn-primary member-invite"><i className="glyphicon glyphicon-envelope"/> Add</a>;
+ } else if (isAdmin && !isMemberAdmin && (member.id != UserStore.getCurrentId())) {
+ var self = this;
+ invite = (
+ <div className="dropdown member-drop">
+ <a href="#" className="dropdown-toggle theme" type="button" id="channel_header_dropdown" data-toggle="dropdown" aria-expanded="true">
+ <span>{member.roles} </span>
+ <span className="caret"></span>
+ </a>
+ <ul className="dropdown-menu member-menu" role="menu" aria-labelledby="channel_header_dropdown">
+ { this.props.handleMakeAdmin ?
+ <li role="presentation"><a role="menuitem" onClick={self.handleMakeAdmin}>Make Admin</a></li>
+ : "" }
+ { this.props.handleRemove ?
+ <li role="presentation"><a role="menuitem" onClick={self.handleRemove}>Remove Member</a></li>
+ : "" }
+ </ul>
+ </div>
+ );
+ } else {
+ invite = <div className="member-drop"><span>{member.roles} </span><span className="caret invisible"></span></div>;
+ }
+
+ var email = member.email.length > 0 ? member.email : "";
+
+ return (
+ <div className="row member-div">
+ <img className="post-profile-img pull-left" src={"/api/v1/users/" + member.id + "/image"} height="36" width="36" />
+ <span className="member-name">{member.username}</span>
+ <span className="member-email">{email}</span>
+ { invite }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/member_list_team.jsx b/web/react/components/member_list_team.jsx
new file mode 100644
index 000000000..3613d97d8
--- /dev/null
+++ b/web/react/components/member_list_team.jsx
@@ -0,0 +1,120 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var Client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+
+var MemberListTeamItem = React.createClass({
+ handleMakeMember: function() {
+ var data = {};
+ data["user_id"] = this.props.user.id;
+ data["new_roles"] = "";
+
+ Client.updateRoles(data,
+ function(data) {
+ AsyncClient.getProfiles();
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ handleMakeActive: function() {
+ Client.updateActive(this.props.user.id, true,
+ function(data) {
+ AsyncClient.getProfiles();
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ handleMakeNotActive: function() {
+ Client.updateActive(this.props.user.id, false,
+ function(data) {
+ AsyncClient.getProfiles();
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ handleMakeAdmin: function() {
+ var data = {};
+ data["user_id"] = this.props.user.id;
+ data["new_roles"] = "admin";
+
+ Client.updateRoles(data,
+ function(data) {
+ AsyncClient.getProfiles();
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return {};
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <div style={{ clear: "both" }} className="has-error"><label className='has-error control-label'>{this.state.server_error}</label></div> : null;
+ var user = this.props.user;
+ var currentRoles = "Member"
+
+ if (user.roles.length > 0) {
+ currentRoles = user.roles.charAt(0).toUpperCase() + user.roles.slice(1);
+ }
+
+ var email = user.email.length > 0 ? user.email : "";
+ var showMakeMember = user.roles == "admin";
+ var showMakeAdmin = user.roles == "";
+ var showMakeActive = false;
+ var showMakeNotActive = true;
+
+ if (user.delete_at > 0) {
+ currentRoles = "Inactive";
+ showMakeMember = false;
+ showMakeAdmin = false;
+ showMakeActive = true;
+ showMakeNotActive = false;
+ }
+
+ return (
+ <div className="row member-div">
+ <img className="post-profile-img pull-left" src={"/api/v1/users/" + user.id + "/image"} height="36" width="36" />
+ <span className="member-name">{user.full_name.trim() ? user.full_name : user.username}</span>
+ <span className="member-email">{user.full_name.trim() ? user.username : email}</span>
+ <div className="dropdown member-drop">
+ <a href="#" className="dropdown-toggle theme" type="button" id="channel_header_dropdown" data-toggle="dropdown" aria-expanded="true">
+ <span>{currentRoles} </span>
+ <span className="caret"></span>
+ </a>
+ <ul className="dropdown-menu member-menu" role="menu" aria-labelledby="channel_header_dropdown">
+ { showMakeAdmin ? <li role="presentation"><a role="menuitem" onClick={this.handleMakeAdmin}>Make Admin</a></li> : "" }
+ { showMakeMember ? <li role="presentation"><a role="menuitem" onClick={this.handleMakeMember}>Make Member</a></li> : "" }
+ { showMakeActive ? <li role="presentation"><a role="menuitem" onClick={this.handleMakeActive}>Make Active</a></li> : "" }
+ { showMakeNotActive ? <li role="presentation"><a role="menuitem" onClick={this.handleMakeNotActive}>Make Inactive</a></li> : "" }
+ </ul>
+ </div>
+ { server_error }
+ </div>
+ );
+ }
+});
+
+
+module.exports = React.createClass({
+ render: function() {
+ return (
+ <div className="member-list-holder">
+ {
+ this.props.users.map(function(user) {
+ return <MemberListTeamItem key={user.id} user={user} />;
+ }, this)
+ }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/mention.jsx b/web/react/components/mention.jsx
new file mode 100644
index 000000000..ba758688b
--- /dev/null
+++ b/web/react/components/mention.jsx
@@ -0,0 +1,16 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ handleClick: function() {
+ this.props.handleClick(this.props.username);
+ },
+ render: function() {
+ return (
+ <div className="mentions-name" onClick={this.handleClick}>
+ <img className="pull-left mention-img" src={"/api/v1/users/" + this.props.id + "/image"}/>
+ <span>@{this.props.username}</span><span style={{'color':'grey', 'marginLeft':'10px'}}>{this.props.name}</span>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx
new file mode 100644
index 000000000..8b7e25b04
--- /dev/null
+++ b/web/react/components/mention_list.jsx
@@ -0,0 +1,127 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Mention = require('./mention.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ PostStore.addMentionDataChangeListener(this._onChange);
+
+ var self = this;
+ $('#'+this.props.id).on('keypress.mentionlist',
+ function(e) {
+ if (!self.isEmpty() && self.state.mentionText != '-1' && e.which === 13) {
+ e.stopPropagation();
+ e.preventDefault();
+ self.addFirstMention();
+ }
+ }
+ );
+ },
+ componentWillUnmount: function() {
+ PostStore.removeMentionDataChangeListener(this._onChange);
+ $('#'+this.props.id).off('keypress.mentionlist');
+ },
+ _onChange: function(id, mentionText, excludeList) {
+ if (id !== this.props.id) return;
+
+ var newState = this.state;
+ if (mentionText != null) newState.mentionText = mentionText;
+ if (excludeList != null) newState.excludeUsers = excludeList;
+ this.setState(newState);
+ },
+ handleClick: function(name) {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.RECIEVED_ADD_MENTION,
+ id: this.props.id,
+ username: name
+ });
+
+ this.setState({ mentionText: '-1' });
+ },
+ addFirstMention: function() {
+ if (!this.refs.mention0) return;
+ this.refs.mention0.handleClick();
+ },
+ isEmpty: function() {
+ return (!this.refs.mention0);
+ },
+ alreadyMentioned: function(username) {
+ var excludeUsers = this.state.excludeUsers;
+ for (var i = 0; i < excludeUsers.length; i++) {
+ if (excludeUsers[i] === username) {
+ return true;
+ }
+ }
+ return false;
+ },
+ getInitialState: function() {
+ return { excludeUsers: [], mentionText: "-1" };
+ },
+ render: function() {
+ var mentionText = this.state.mentionText;
+ if (mentionText === '-1') return (<div/>);
+
+ var profiles = UserStore.getActiveOnlyProfiles();
+ var users = [];
+ for (var id in profiles) {
+ users.push(profiles[id]);
+ }
+
+ users.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+ var mentions = {};
+ var index = 0;
+
+ for (var i = 0; i < users.length; i++) {
+ if (Object.keys(mentions).length >= 25) break;
+ if (this.alreadyMentioned(users[i].username)) continue;
+
+ var firstName = "", lastName = "";
+ if (users[i].full_name.length > 0) {
+ var splitName = users[i].full_name.split(' ');
+ firstName = splitName[0].toLowerCase();
+ lastName = splitName.length > 1 ? splitName[splitName.length-1].toLowerCase() : "";
+ }
+
+ if (firstName.lastIndexOf(mentionText,0) === 0
+ || lastName.lastIndexOf(mentionText,0) === 0 || users[i].username.lastIndexOf(mentionText,0) === 0) {
+ mentions[i+1] = (
+ <Mention
+ ref={'mention' + index}
+ username={users[i].username}
+ name={users[i].full_name}
+ id={users[i].id}
+ handleClick={this.handleClick} />
+ );
+ index++;
+ }
+ }
+ var numMentions = Object.keys(mentions).length;
+
+ if (numMentions < 1) return (<div/>);
+
+ var height = (numMentions*37) + 2;
+ var width = $('#'+this.props.id).parent().width();
+ var bottom = $(window).height() - $('#'+this.props.id).offset().top;
+ var left = $('#'+this.props.id).offset().left;
+ var max_height = $('#'+this.props.id).offset().top - 10;
+
+ return (
+ <div className="mentions--top" style={{height: height, width: width, bottom: bottom, left: left}}>
+ <div ref="mentionlist" className="mentions-box" style={{maxHeight: max_height, height: height, width: width}}>
+ { mentions }
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/message_wrapper.jsx b/web/react/components/message_wrapper.jsx
new file mode 100644
index 000000000..5fc88a61b
--- /dev/null
+++ b/web/react/components/message_wrapper.jsx
@@ -0,0 +1,17 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var utils = require('../utils/utils.jsx');
+
+module.exports = React.createClass({
+ render: function() {
+ if (this.props.message) {
+ var inner = utils.textToJsx(this.props.message, this.props.options);
+ return (
+ <div>{inner}</div>
+ );
+ } else {
+ return <div/>
+ }
+ }
+});
diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx
new file mode 100644
index 000000000..be2a5e93c
--- /dev/null
+++ b/web/react/components/more_channels.jsx
@@ -0,0 +1,109 @@
+// 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');
+var ChannelStore = require('../stores/channel_store.jsx');
+
+function getStateFromStores() {
+ return {
+ channels: ChannelStore.getMoreAll(),
+ server_error: null
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addMoreChangeListener(this._onChange);
+ $(this.refs.modal.getDOMNode()).on('shown.bs.modal', function (e) {
+ asyncClient.getMoreChannels(true);
+ });
+
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ channel_type: $(button).attr('data-channeltype') });
+ });
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeMoreChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState.channels, this.state.channels)) {
+ this.setState(newState);
+ }
+ },
+ getInitialState: function() {
+ var initState = getStateFromStores();
+ initState.channel_type = "";
+ return initState;
+ },
+ handleJoin: function(e) {
+ var self = this;
+ client.joinChannel(e,
+ function(data) {
+ $(self.refs.modal.getDOMNode()).modal('hide');
+ asyncClient.getChannels(true);
+ }.bind(this),
+ function(err) {
+ this.state.server_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ },
+ handleNewChannel: function() {
+ $(this.refs.modal.getDOMNode()).modal('hide');
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+ var outter = this;
+
+ return (
+ <div className="modal fade" id="more_channels" 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">Close</span>
+ </button>
+ <h4 className="modal-title">More Channels</h4>
+ <button data-toggle="modal" data-target="#new_channel" data-channeltype={this.state.channel_type} type="button" className="btn btn-primary channel-create-btn" onClick={this.handleNewChannel}>Create New Channel</button>
+ </div>
+ <div className="modal-body">
+ {this.state.channels.length ?
+ <table className="more-channel-table table">
+ <tbody>
+ {this.state.channels.map(function(channel) {
+ return (
+ <tr key={channel.id}>
+ <td>
+ <p className="more-channel-name">{channel.display_name}</p>
+ <p className="more-channel-description">{channel.description}</p>
+ </td>
+ <td className="td--action"><button onClick={outter.handleJoin.bind(outter, channel.id)} className="pull-right btn btn-primary">Join</button></td>
+ </tr>
+ )
+ })}
+ </tbody>
+ </table>
+ : <div className="no-channel-message">
+ <p className="primary-message">No more channels to join</p>
+ <p className="secondary-message">Click 'Create New Channel' to make a new one</p>
+ </div>}
+ { server_error }
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ );
+ }
+});
diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx
new file mode 100644
index 000000000..2785dc8e0
--- /dev/null
+++ b/web/react/components/more_direct_channels.jsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var utils = require('../utils/utils.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ channels: $(button).data('channels') });
+ });
+ },
+ getInitialState: function() {
+ return { channels: [] };
+ },
+ render: function() {
+ var self = this;
+
+ var directMessageItems = this.state.channels.map(function(channel) {
+ var badge = "";
+ var titleClass = ""
+
+ if (!channel.fake) {
+ var active = channel.id === ChannelStore.getCurrentId() ? "active" : "";
+
+ if (channel.unread) {
+ badge = <span className="badge pull-right small">{channel.unread}</span>;
+ badgesActive = true;
+ titleClass = "unread-title"
+ }
+ return (
+ <li key={channel.name} className={active}><a className={"sidebar-channel " + titleClass} href="#" onClick={function(e){e.preventDefault(); utils.switchChannel(channel, channel.teammate_username); $(self.refs.modal.getDOMNode()).modal('hide')}}>{badge}{channel.display_name}</a></li>
+ );
+ } else {
+ return (
+ <li key={channel.name} className={active}><a className={"sidebar-channel " + titleClass} href={"/channels/"+channel.name}>{badge}{channel.display_name}</a></li>
+ );
+ }
+ });
+
+ return (
+ <div className="modal fade" id="more_direct_channels" 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">Close</span>
+ </button>
+ <h4 className="modal-title">More Direct Messages</h4>
+ </div>
+ <div className="modal-body">
+ <ul className="nav nav-pills nav-stacked">
+ {directMessageItems}
+ </ul>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ );
+ }
+});
diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx
new file mode 100644
index 000000000..9d3904757
--- /dev/null
+++ b/web/react/components/msg_typing.jsx
@@ -0,0 +1,49 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var SocketStore = require('../stores/socket_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+
+module.exports = React.createClass({
+ timer: null,
+ lastTime: 0,
+ componentDidMount: function() {
+ SocketStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ SocketStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function(msg) {
+ if (msg.action == "typing" &&
+ this.props.channelId == msg.channel_id &&
+ this.props.parentId == msg.props.parent_id) {
+
+ this.lastTime = new Date().getTime();
+
+ var username = "Someone";
+ if (UserStore.hasProfile(msg.user_id)) {
+ username = UserStore.getProfile(msg.user_id).username;
+ }
+
+ this.setState({ text: username + " is typing..." });
+
+ if (!this.timer) {
+ var outer = this;
+ outer.timer = setInterval(function() {
+ if ((new Date().getTime() - outer.lastTime) > 8000) {
+ outer.setState({ text: "" });
+ }
+ }, 3000);
+ }
+ }
+ },
+ getInitialState: function() {
+ return { text: "" };
+ },
+ render: function() {
+ return (
+ <span className="msg-typing">{ this.state.text }</span>
+ );
+ }
+});
diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx
new file mode 100644
index 000000000..3821c2772
--- /dev/null
+++ b/web/react/components/navbar.jsx
@@ -0,0 +1,351 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var utils = require('../utils/utils.jsx');
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var Sidebar = require('./sidebar.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var Constants = require('../utils/constants.jsx');
+var UserProfile = require('./user_profile.jsx');
+var MessageWrapper = require('./message_wrapper.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+function getCountsStateFromStores() {
+
+ var count = 0;
+ var channels = ChannelStore.getAll();
+ var members = ChannelStore.getAllMembers();
+
+ channels.forEach(function(channel) {
+ var channelMember = members[channel.id];
+ if (channel.type === 'D') {
+ count += channel.total_msg_count - channelMember.msg_count;
+ } else {
+ if (channelMember.mention_count > 0) {
+ count += channelMember.mention_count;
+ } else if (channel.total_msg_count - channelMember.msg_count > 0) {
+ count += 1;
+ }
+ }
+ });
+
+ return { count: count }
+}
+
+var NotifyCounts = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getCountsStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ },
+ getInitialState: function() {
+ return getCountsStateFromStores();
+ },
+ render: function() {
+ if (this.state.count == 0) {
+ return (<span></span>);
+ }
+ else {
+ return (<span className="badge badge-notify">{ this.state.count }</span>);
+ }
+ }
+});
+
+var NavbarLoginForm = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ var state = { }
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.server_error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ var email = this.refs.email.getDOMNode().value.trim();
+ if (!email) {
+ state.server_error = "An email is required"
+ this.setState(state);
+ return;
+ }
+
+ var password = this.refs.password.getDOMNode().value.trim();
+ if (!password) {
+ state.server_error = "A password is required"
+ this.setState(state);
+ return;
+ }
+
+ state.server_error = "";
+ this.setState(state);
+
+ client.loginByEmail(domain, email, password,
+ function(data) {
+ UserStore.setLastDomain(domain);
+ UserStore.setLastEmail(email);
+ UserStore.setCurrentUser(data);
+
+ var redirect = utils.getUrlParameter("redirect");
+ if (redirect) {
+ window.location.href = decodeURI(redirect);
+ } else {
+ window.location.href = '/channels/town-square';
+ }
+
+ }.bind(this),
+ function(err) {
+ if (err.message == "Login failed because email address has not been verified") {
+ window.location.href = '/verify?domain=' + encodeURIComponent(domain) + '&email=' + encodeURIComponent(email);
+ return;
+ }
+ state.server_error = err.message;
+ this.valid = false;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <label className="control-label">{this.state.server_error}</label> : null;
+
+ var subDomain = utils.getSubDomain();
+ var subDomainClass = "form-control hidden";
+
+ if (subDomain == "") {
+ subDomain = UserStore.getLastDomain();
+ subDomainClass = "form-control";
+ }
+
+ return (
+ <form className="navbar-form navbar-right" onSubmit={this.handleSubmit}>
+ <a href="/find_team">Find your team</a>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ { server_error }
+ <input type="text" className={subDomainClass} name="domain" defaultValue={subDomain} ref="domain" placeholder="Domain" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="text" className="form-control" name="email" defaultValue={UserStore.getLastEmail()} ref="email" placeholder="Email" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="password" className="form-control" name="password" ref="password" placeholder="Password" />
+ </div>
+ <button type="submit" className="btn btn-default">Login</button>
+ </form>
+ );
+ }
+});
+
+function getStateFromStores() {
+ return {
+ channel: ChannelStore.getCurrent(),
+ member: ChannelStore.getCurrentMember(),
+ users: ChannelStore.getCurrentExtraInfo().members
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+ ChannelStore.addExtraInfoChangeListener(this._onChange);
+ var self = this;
+ $('.inner__wrap').click(self.hideSidebars);
+
+ $('body').on('click.infopopover', function(e){
+ if ($(e.target).attr('data-toggle') !== 'popover'
+ && $(e.target).parents('.popover.in').length === 0) {
+ $('.info-popover').popover('hide');
+ }
+ });
+
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ handleSubmit: function(e) {
+ e.preventDefault();
+ },
+ handleLeave: function(e) {
+ client.leaveChannel(this.state.channel.id,
+ function(data) {
+ AsyncClient.getChannels(true);
+ window.location.href = '/channels/town-square';
+ }.bind(this),
+ function(err) {
+ AsyncClient.dispatchError(err, "handleLeave");
+ }.bind(this)
+ );
+ },
+ hideSidebars: function(e) {
+ var windowWidth = $(window).outerWidth();
+ if(windowWidth <= 768) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+
+ if (e.target.className != 'navbar-toggle' && e.target.className != 'icon-bar') {
+ $('.inner__wrap').removeClass('move--right').removeClass('move--left').removeClass('move--left-small');
+ $('.sidebar--left').removeClass('move--right');
+ $('.sidebar--right').removeClass('move--left');
+ $('.sidebar--menu').removeClass('move--left');
+ }
+ }
+ },
+ toggleLeftSidebar: function() {
+ $('.inner__wrap').toggleClass('move--right');
+ $('.sidebar--left').toggleClass('move--right');
+ },
+ toggleRightSidebar: function() {
+ $('.inner__wrap').toggleClass('move--left-small');
+ $('.sidebar--menu').toggleClass('move--left');
+ },
+ _onChange: function() {
+ this.setState(getStateFromStores());
+ $("#navbar .navbar-brand .description").popover({placement : 'bottom', trigger: 'click', html: true});
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+
+ var currentId = UserStore.getCurrentId();
+ var channelName = "";
+ var popoverContent = "";
+ var channelTitle = this.props.teamName;
+ var isAdmin = false;
+ var isDirect = false;
+ var description = ""
+
+ if (this.state.channel) {
+ var channel = this.state.channel;
+ description = utils.textToJsx(this.state.channel.description, {"singleline": true, "noMentionHighlight": true});
+ popoverContent = React.renderToString(<MessageWrapper message={this.state.channel.description}/>);
+ channelName = this.state.channel.name;
+ isAdmin = this.state.member.roles.indexOf("admin") > -1;
+
+ if (channel.type === 'O') {
+ channelTitle = this.state.channel.display_name;
+ } else if (channel.type === 'P') {
+ channelTitle = this.state.channel.display_name;
+ } else if (channel.type === 'D') {
+ isDirect = true;
+ if (this.state.users.length > 1) {
+ if (this.state.users[0].id === currentId) {
+ channelTitle = <UserProfile userId={this.state.users[1].id} />;
+ } else {
+ channelTitle = <UserProfile userId={this.state.users[0].id} />;
+ }
+ }
+ }
+
+ if(this.state.channel.description.length == 0){
+ popoverContent = React.renderToString(<div>No channel description yet. <br /><a href='#' data-toggle='modal' data-desc={this.state.channel.description} data-title={this.state.channel.display_name} data-channelid={this.state.channel.id} data-target='#edit_channel'>Click here</a> to add one.</div>);
+ }
+ }
+
+ var loginForm = currentId == null ? <NavbarLoginForm /> : null;
+ var navbar_collapse_button = currentId != null ? null :
+ <button type="button" className="navbar-toggle" data-toggle="collapse" data-target="#navbar-collapse-1">
+ <span className="sr-only">Toggle sidebar</span>
+ <span className="icon-bar"></span>
+ <span className="icon-bar"></span>
+ <span className="icon-bar"></span>
+ </button>;
+ var sidebar_collapse_button = currentId == null ? null :
+ <button type="button" className="navbar-toggle" data-toggle="collapse" data-target="#sidebar-nav" onClick={this.toggleLeftSidebar}>
+ <span className="sr-only">Toggle sidebar</span>
+ <span className="icon-bar"></span>
+ <span className="icon-bar"></span>
+ <span className="icon-bar"></span>
+ <NotifyCounts />
+ </button>;
+ var right_sidebar_collapse_button= currentId == null ? null :
+ <button type="button" className="navbar-toggle menu-toggle pull-right" data-toggle="collapse" data-target="#sidebar-nav" onClick={this.toggleRightSidebar}>
+ <span className="dropdown__icon"></span>
+ </button>;
+
+
+ return (
+ <nav className="navbar navbar-default navbar-fixed-top" role="navigation">
+ <div className="container-fluid theme">
+ <div className="navbar-header">
+ { navbar_collapse_button }
+ { sidebar_collapse_button }
+ { right_sidebar_collapse_button }
+ { !isDirect && this.state.channel ?
+ <div className="navbar-brand">
+ <div className="dropdown">
+ <div data-toggle="popover" data-content={popoverContent} className="description info-popover"></div>
+ <a href="#" className="dropdown-toggle theme" type="button" id="channel_header_dropdown" data-toggle="dropdown" aria-expanded="true">
+ <strong className="heading">{channelTitle} </strong>
+ <span className="glyphicon glyphicon-chevron-down header-dropdown__icon"></span>
+ </a>
+ <ul className="dropdown-menu" role="menu" aria-labelledby="channel_header_dropdown">
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Invite Members</a></li>
+ { isAdmin ?
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_members" href="#">Manage Members</a></li>
+ : ""
+ }
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#edit_channel" data-desc={this.state.channel.description} data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Set Channel Description...</a></li>
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#channel_notifications" data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Notification Preferences</a></li>
+ { isAdmin && channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#rename_channel" data-display={this.state.channel.display_name} data-name={this.state.channel.name} data-channelid={this.state.channel.id}>Rename Channel...</a></li>
+ : ""
+ }
+ { isAdmin && channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" data-toggle="modal" data-target="#delete_channel" data-title={this.state.channel.display_name} data-channelid={this.state.channel.id}>Delete Channel...</a></li>
+ : ""
+ }
+ { channelName != Constants.DEFAULT_CHANNEL ?
+ <li role="presentation"><a role="menuitem" href="#" onClick={this.handleLeave}>Leave Channel</a></li>
+ : ""
+ }
+ </ul>
+ </div>
+ </div>
+ : "" }
+ { isDirect && this.state.channel ?
+ <div className="navbar-brand">
+ <strong>
+ <a href="#"><strong className="heading">{channelTitle}</strong></a>
+ </strong>
+ </div>
+ : "" }
+ { !this.state.channel ?
+ <div className="navbar-brand">
+ <strong>
+ <a href="/"><strong className="heading">{ channelTitle }</strong></a>
+ </strong>
+ </div>
+ : "" }
+ </div>
+ <div className="collapse navbar-collapse" id="navbar-collapse-1">
+ { loginForm }
+ </div>
+ </div>
+ </nav>
+ );
+}
+});
+
+
diff --git a/web/react/components/new_channel.jsx b/web/react/components/new_channel.jsx
new file mode 100644
index 000000000..13fa5b2cc
--- /dev/null
+++ b/web/react/components/new_channel.jsx
@@ -0,0 +1,139 @@
+// 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');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ var channel = {};
+ var state = { server_error: "" };
+
+ channel.display_name = this.refs.display_name.getDOMNode().value.trim();
+ if (!channel.display_name) {
+ state.display_name_error = "This field is required";
+ state.inValid = true;
+ }
+ else if (channel.display_name.length > 22) {
+ state.display_name_error = "This field must be less than 22 characters";
+ state.inValid = true;
+ }
+ else {
+ state.display_name_error = "";
+ }
+
+ channel.name = this.refs.channel_name.getDOMNode().value.trim();
+ if (!channel.name) {
+ state.name_error = "This field is required";
+ state.inValid = true;
+ }
+ else if(channel.name.length > 22){
+ state.name_error = "This field must be less than 22 characters";
+ state.inValid = true;
+ }
+ else {
+ var cleaned_name = utils.cleanUpUrlable(channel.name);
+ if (cleaned_name != channel.name) {
+ state.name_error = "Must be lowercase alphanumeric characters, allowing '-' but not starting or ending with '-'";
+ state.inValid = true;
+ }
+ else {
+ state.name_error = "";
+ }
+ }
+
+ this.setState(state);
+
+ if (state.inValid)
+ return;
+
+ var cu = UserStore.getCurrentUser();
+ channel.team_id = cu.team_id;
+
+ channel.description = this.refs.channel_desc.getDOMNode().value.trim();
+ channel.type = this.state.channel_type;
+
+ var self = this;
+ client.createChannel(channel,
+ function(data) {
+ this.refs.display_name.getDOMNode().value = "";
+ this.refs.channel_name.getDOMNode().value = "";
+ this.refs.channel_desc.getDOMNode().value = "";
+
+ $(self.refs.modal.getDOMNode()).modal('hide');
+ window.location.href = "/channels/" + channel.name;
+ asyncClient.getChannels(true);
+ }.bind(this),
+ function(err) {
+ state.server_error = err.message;
+ state.inValid = true;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ displayNameKeyUp: function(e) {
+ var display_name = this.refs.display_name.getDOMNode().value.trim();
+ var channel_name = utils.cleanUpUrlable(display_name);
+ this.refs.channel_name.getDOMNode().value = channel_name;
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = e.relatedTarget;
+ self.setState({ channel_type: $(button).attr('data-channeltype') });
+ });
+ },
+ getInitialState: function() {
+ return { channel_type: "" };
+ },
+ render: function() {
+
+ var display_name_error = this.state.display_name_error ? <label className='control-label'>{ this.state.display_name_error }</label> : null;
+ var name_error = this.state.name_error ? <label className='control-label'>{ this.state.name_error }</label> : null;
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ 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">Close</span>
+ </button>
+ <h4 className="modal-title">New Channel</h4>
+ </div>
+ <div className="modal-body">
+ <form role="form">
+ <div className={ this.state.display_name_error ? "form-group has-error" : "form-group" }>
+ <label className='control-label'>Display Name</label>
+ <input onKeyUp={this.displayNameKeyUp} type="text" ref="display_name" className="form-control" placeholder="Enter display name" maxLength="64" />
+ { display_name_error }
+ </div>
+ <div className={ this.state.name_error ? "form-group has-error" : "form-group" }>
+ <label className='control-label'>Handle</label>
+ <input type="text" className="form-control" ref="channel_name" placeholder="lowercase alphanumeric's only" maxLength="64" />
+ { name_error }
+ </div>
+ <div className="form-group">
+ <label className='control-label'>Description</label>
+ <textarea className="form-control" ref="channel_desc" rows="3" placeholder="Description" maxLength="1024"></textarea>
+ </div>
+ { server_error }
+ </form>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Create New Channel</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/password_reset.jsx b/web/react/components/password_reset.jsx
new file mode 100644
index 000000000..24566c7b1
--- /dev/null
+++ b/web/react/components/password_reset.jsx
@@ -0,0 +1,178 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+
+SendResetPasswordLink = React.createClass({
+ handleSendLink: function(e) {
+ e.preventDefault();
+ var state = {};
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ var email = this.refs.email.getDOMNode().value.trim();
+ if (!email) {
+ state.error = "Please enter a valid email address."
+ this.setState(state);
+ return;
+ }
+
+ state.error = null;
+ this.setState(state);
+
+ data = {};
+ data['email'] = email;
+ data['domain'] = domain;
+
+ client.sendPasswordReset(data,
+ function(data) {
+ this.setState({ error: null, update_text: <p>A password reset link has been sent to <b>{email}</b> for your <b>{this.props.teamName}</b> team on {config.SiteName}.com.</p>, more_update_text: "Please check your inbox." });
+ $(this.refs.reset_form.getDOMNode()).hide();
+ }.bind(this),
+ function(err) {
+ this.setState({ error: err.message, update_text: null, more_update_text: null });
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return {};
+ },
+ render: function() {
+ var update_text = this.state.update_text ? <div className="reset-form alert alert-success">{this.state.update_text}{this.state.more_update_text}</div> : null;
+ var error = this.state.error ? <div className="form-group has-error"><label className="control-label">{this.state.error}</label></div> : null;
+
+ var subDomain = utils.getSubDomain();
+ var subDomainClass = "form-control hidden";
+
+ if (subDomain == "") {
+ subDomain = UserStore.getLastDomain();
+ subDomainClass = "form-control";
+ }
+
+ return (
+ <div className="col-sm-12">
+ <div className="signup-team__container">
+ <h3>Password Reset</h3>
+ { update_text }
+ <form onSubmit={this.handleSendLink} ref="reset_form">
+ <p>{"To reset your password, enter the email address you used to sign up for " + this.props.teamName + "."}</p>
+ <div className="form-group">
+ <input type="text" className={subDomainClass} name="domain" defaultValue={subDomain} ref="domain" placeholder="Domain" />
+ </div>
+ <div className={error ? 'form-group has-error' : 'form-group'}>
+ <input type="text" className="form-control" name="email" ref="email" placeholder="Email" />
+ </div>
+ { error }
+ <button type="submit" className="btn btn-primary">Reset my password</button>
+ </form>
+ </div>
+ </div>
+ );
+ }
+});
+
+ResetPassword = React.createClass({
+ handlePasswordReset: function(e) {
+ e.preventDefault();
+ var state = {};
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ var password = this.refs.password.getDOMNode().value.trim();
+ if (!password || password.length < 5) {
+ state.error = "Please enter at least 5 characters."
+ this.setState(state);
+ return;
+ }
+
+ state.error = null;
+ this.setState(state);
+
+ data = {};
+ data['new_password'] = password;
+ data['hash'] = this.props.hash;
+ data['data'] = this.props.data;
+ data['domain'] = domain;
+
+ client.resetPassword(data,
+ function(data) {
+ this.setState({ error: null, update_text: "Your password has been updated successfully." });
+ }.bind(this),
+ function(err) {
+ this.setState({ error: err.message, update_text: null });
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return {};
+ },
+ render: function() {
+ var update_text = this.state.update_text ? <div className="form-group"><br/><label className="control-label reset-form">{this.state.update_text} Click <a href="/login">here</a> to log in.</label></div> : null;
+ var error = this.state.error ? <div className="form-group has-error"><label className="control-label">{this.state.error}</label></div> : null;
+
+ var subDomain = this.props.domain != "" ? this.props.domain : utils.getSubDomain();
+ var subDomainClass = "form-control hidden";
+
+ if (subDomain == "") {
+ subDomain = UserStore.getLastDomain();
+ subDomainClass = "form-control";
+ }
+
+ return (
+ <div className="col-sm-12">
+ <div className="signup-team__container">
+ <h3>Password Reset</h3>
+ <form onSubmit={this.handlePasswordReset}>
+ <p>{"Enter a new password for your " + this.props.teamName + " " + config.SiteName + " account."}</p>
+ <div className="form-group">
+ <input type="text" className={subDomainClass} name="domain" defaultValue={subDomain} ref="domain" placeholder="Domain" />
+ </div>
+ <div className={error ? 'form-group has-error' : 'form-group'}>
+ <input type="password" className="form-control" name="password" ref="password" placeholder="Password" />
+ </div>
+ { error }
+ <button type="submit" className="btn btn-primary">Change my password</button>
+ { update_text }
+ </form>
+ </div>
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ getInitialState: function() {
+ return {};
+ },
+ render: function() {
+
+ if (this.props.isReset === "false") {
+ return (
+ <SendResetPasswordLink
+ teamName={this.props.teamName}
+ />
+ );
+ } else {
+ return (
+ <ResetPassword
+ teamName={this.props.teamName}
+ domain={this.props.domain}
+ hash={this.props.hash}
+ data={this.props.data}
+ />
+ );
+ }
+ }
+});
diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx
new file mode 100644
index 000000000..afe978495
--- /dev/null
+++ b/web/react/components/post.jsx
@@ -0,0 +1,88 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PostHeader = require('./post_header.jsx');
+var PostBody = require('./post_body.jsx');
+var PostInfo = require('./post_info.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ $('.edit-modal').on('show.bs.modal', function () {
+ $('.edit-modal .edit-modal-body').css('overflow-y', 'auto');
+ $('.edit-modal .edit-modal-body').css('max-height', $(window).height() * 0.7);
+ });
+ },
+ handleCommentClick: function(e) {
+ e.preventDefault();
+
+ data = {};
+ data.order = [this.props.post.id];
+ data.posts = this.props.posts;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ post_list: data
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var post = this.props.post;
+ var parentPost = this.props.parentPost;
+ var posts = this.props.posts;
+
+ var type = "Post"
+ if (post.root_id.length > 0) {
+ type = "Comment"
+ }
+
+ var commentCount = 0;
+ var commentRootId = parentPost ? post.root_id : post.id;
+ var rootUser = "";
+ for (var postId in posts) {
+ if (posts[postId].root_id == commentRootId) {
+ commentCount += 1;
+ }
+ }
+
+ var error = this.state.error ? <div className='form-group has-error'><label className='control-label'>{ this.state.error }</label></div> : null;
+
+ if(this.props.sameRoot){
+ rootUser = "same--root";
+ }
+ else {
+ rootUser = "other--root";
+ }
+
+ var postType = "";
+ if(type != "Post"){
+ postType = "post--comment";
+ }
+
+ return (
+ <div>
+ <div id={post.id} className={"post " + this.props.sameUser + " " + rootUser + " " + postType}>
+ { !this.props.hideProfilePic ?
+ <div className="post-profile-img__container">
+ <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image"} height="36" width="36" />
+ </div>
+ : "" }
+ <div className="post__content">
+ <PostHeader post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} />
+ <PostBody post={post} sameRoot={this.props.sameRoot} parentPost={parentPost} posts={posts} handleCommentClick={this.handleCommentClick} />
+ <PostInfo post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} allowReply="true" />
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
new file mode 100644
index 000000000..55fc32c33
--- /dev/null
+++ b/web/react/components/post_body.jsx
@@ -0,0 +1,141 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var CreateComment = require( './create_comment.jsx' );
+var UserStore = require('../stores/user_store.jsx');
+var utils = require('../utils/utils.jsx');
+var ViewImageModal = require('./view_image.jsx');
+var Constants = require('../utils/constants.jsx');
+
+module.exports = React.createClass({
+ handleImageClick: function(e) {
+ this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))});
+ },
+ componentWillReceiveProps: function(nextProps) {
+ var linkData = utils.extractLinks(nextProps.post.message);
+ this.setState({ links: linkData["links"], message: linkData["text"] });
+ },
+ componentDidMount: function() {
+ var filenames = this.props.post.filenames;
+ var self = this;
+ if (filenames) {
+ var re1 = new RegExp(' ', 'g');
+ var re2 = new RegExp('\\(', 'g');
+ var re3 = new RegExp('\\)', 'g');
+ for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
+ var fileInfo = utils.splitFileLocation(filenames[i]);
+ if (Object.keys(fileInfo).length === 0) continue;
+
+ var type = utils.getFileType(fileInfo.ext);
+
+ if (type === "image") {
+ $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() {
+ $(this).remove();
+ if (name in self.refs) {
+ var imgDiv = self.refs[name].getDOMNode();
+ $(imgDiv).removeClass('post__load');
+ $(imgDiv).addClass('post__image');
+ var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
+ $(imgDiv).css('background-image', 'url('+url+'_thumb.jpg)');
+ }
+ }}(fileInfo.path, filenames[i]));
+ }
+ }
+ }
+ },
+ getInitialState: function() {
+ var linkData = utils.extractLinks(this.props.post.message);
+ return { startImgId: 0, links: linkData["links"], message: linkData["text"] };
+ },
+ render: function() {
+ var post = this.props.post;
+ var filenames = this.props.post.filenames;
+ var parentPost = this.props.parentPost;
+ var postImageModalId = "view_image_modal_" + post.id;
+ var inner = utils.textToJsx(this.state.message);
+
+ var comment = "";
+ var reply = "";
+ var postClass = "";
+
+ if (parentPost) {
+ var profile = UserStore.getProfile(parentPost.user_id);
+ var apostrophe = "";
+ var name = "...";
+ if (profile != null) {
+ if (profile.username.slice(-1) === 's') {
+ apostrophe = "'";
+ } else {
+ apostrophe = "'s";
+ }
+ name = <a className="theme" onClick={function(){ utils.searchForTerm(profile.username); }}>{profile.username}</a>;
+ }
+
+ var message = parentPost.message;
+
+ comment = (
+ <p className="post-link">
+ <span>Commented on {name}{apostrophe} message: <a className="theme" onClick={this.props.handleCommentClick}>{utils.replaceHtmlEntities(message)}</a></span>
+ </p>
+ );
+
+ postClass += " post-comment";
+ }
+
+ var postFiles = [];
+ var images = [];
+ if (filenames) {
+ for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
+ var fileInfo = utils.splitFileLocation(filenames[i]);
+ if (Object.keys(fileInfo).length === 0) continue;
+
+ var type = utils.getFileType(fileInfo.ext);
+
+ if (type === "image") {
+ postFiles.push(
+ <div className="post-image__column" key={filenames[i]}>
+ <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filenames[i]} className="post__load" style={{backgroundImage: 'url(/static/images/load.gif)'}}></div></a>
+ </div>
+ );
+ images.push(filenames[i]);
+ } else {
+ postFiles.push(
+ <div className="post-image__column custom-file" key={fileInfo.name}>
+ <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}>
+ <div className={"file-icon "+utils.getIconClassName(type)}/>
+ </a>
+ </div>
+ );
+ }
+ }
+ }
+
+ var embed;
+ if (postFiles.length === 0 && this.state.links) {
+ embed = utils.getEmbed(this.state.links[0]);
+ }
+
+ return (
+ <div className="post-body">
+ { comment }
+ <p key={post.Id+"_message"} className={postClass}><span>{inner}</span></p>
+ { filenames && filenames.length > 0 ?
+ <div className="post-image__columns">
+ { postFiles }
+ </div>
+ : "" }
+ { embed }
+
+ { images.length > 0 ?
+ <ViewImageModal
+ channelId={post.channel_id}
+ userId={post.user_id}
+ modalId={postImageModalId}
+ startId={this.state.startImgId}
+ imgCount={post.img_count}
+ filenames={images} />
+ : "" }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/post_deleted_modal.jsx b/web/react/components/post_deleted_modal.jsx
new file mode 100644
index 000000000..307120df3
--- /dev/null
+++ b/web/react/components/post_deleted_modal.jsx
@@ -0,0 +1,36 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+
+module.exports = React.createClass({
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var currentUser = UserStore.getCurrentUser()
+
+ if (currentUser != null) {
+ return (
+ <div className="modal fade" ref="modal" id="post_deleted" 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" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" id="myModalLabel">Comment could not be posted</h4>
+ </div>
+ <div className="modal-body">
+ <p>Someone deleted the message on which you tried to post a comment.</p>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-primary" data-dismiss="modal">Agree</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/post_header.jsx b/web/react/components/post_header.jsx
new file mode 100644
index 000000000..129db6d14
--- /dev/null
+++ b/web/react/components/post_header.jsx
@@ -0,0 +1,23 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserProfile = require( './user_profile.jsx' );
+var PostInfo = require('./post_info.jsx');
+
+module.exports = React.createClass({
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var post = this.props.post;
+
+ return (
+ <ul className="post-header post-header-post">
+ <li className="post-header-col post-header__name"><strong><UserProfile userId={post.user_id} /></strong></li>
+ <li className="post-info--hidden">
+ <PostInfo post={post} commentCount={this.props.commentCount} handleCommentClick={this.props.handleCommentClick} allowReply="true" isLastComment={this.props.isLastComment} />
+ </li>
+ </ul>
+ );
+ }
+});
diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx
new file mode 100644
index 000000000..cf01747f0
--- /dev/null
+++ b/web/react/components/post_info.jsx
@@ -0,0 +1,52 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var utils = require('../utils/utils.jsx');
+
+module.exports = React.createClass({
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var post = this.props.post;
+ var isOwner = UserStore.getCurrentId() == post.user_id;
+
+ var type = "Post"
+ if (post.root_id.length > 0) {
+ type = "Comment"
+ }
+
+ var comments = "";
+ var lastCommentClass = this.props.isLastComment ? " comment-icon__container__show" : " comment-icon__container__hide";
+ if (this.props.commentCount >= 1) {
+ comments = <a href="#" className={"comment-icon__container theme" + lastCommentClass} onClick={this.props.handleCommentClick}><span className="comment-icon" dangerouslySetInnerHTML={{__html: "<svg version='1.1' id='Layer_2' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='15px' height='15px' viewBox='1 1.5 15 15' enable-background='new 1 1.5 15 15' xml:space='preserve'> <g> <g> <path fill='#211B1B' d='M14,1.5H3c-1.104,0-2,0.896-2,2v8c0,1.104,0.896,2,2,2h1.628l1.884,3l1.866-3H14c1.104,0,2-0.896,2-2v-8 C16,2.396,15.104,1.5,14,1.5z M15,11.5c0,0.553-0.447,1-1,1H8l-1.493,2l-1.504-1.991L5,12.5H3c-0.552,0-1-0.447-1-1v-8 c0-0.552,0.448-1,1-1h11c0.553,0,1,0.448,1,1V11.5z'/> </g> </g> </svg>"}} />{this.props.commentCount}</a>;
+ }
+
+ return (
+ <ul className="post-header post-info">
+ <li className="post-header-col"><time className="post-profile-time">{ utils.displayDateTime(post.create_at) }</time></li>
+ <li className="post-header-col post-header__reply">
+ <div className="dropdown">
+ { isOwner || (this.props.allowReply === "true" && type != "Comment") ?
+ <div>
+ <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false">
+ [...]
+ </a>
+ <ul className="dropdown-menu" role="menu">
+ { isOwner ? <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id} data-comments={type === "Post" ? this.props.commentCount : 0}>Edit</a></li>
+ : "" }
+ { isOwner ? <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={type === "Post" ? this.props.commentCount : 0}>Delete</a></li>
+ : "" }
+ { this.props.allowReply === "true" ? <li role="presentation"><a className="reply-link theme" href="#" onClick={this.props.handleCommentClick}>Reply</a></li>
+ : "" }
+ </ul>
+ </div>
+ : "" }
+ </div>
+ { comments }
+ </li>
+ </ul>
+ );
+ }
+});
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
new file mode 100644
index 000000000..65247b705
--- /dev/null
+++ b/web/react/components/post_list.jsx
@@ -0,0 +1,474 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PostStore = require('../stores/post_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var UserProfile = require( './user_profile.jsx' );
+var AsyncClient = require('../utils/async_client.jsx');
+var CreatePost = require('./create_post.jsx');
+var Post = require('./post.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var utils = require('../utils/utils.jsx');
+var Client = require('../utils/client.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+function getStateFromStores() {
+ var channel = ChannelStore.getCurrent();
+
+ if (channel == null) channel = {};
+
+ return {
+ post_list: PostStore.getCurrentPosts(),
+ channel: channel
+ };
+}
+
+function changeColor(col, amt) {
+
+ var usePound = false;
+
+ if (col[0] == "#") {
+ col = col.slice(1);
+ usePound = true;
+ }
+
+ var num = parseInt(col,16);
+
+ var r = (num >> 16) + amt;
+
+ if (r > 255) r = 255;
+ else if (r < 0) r = 0;
+
+ var b = ((num >> 8) & 0x00FF) + amt;
+
+ if (b > 255) b = 255;
+ else if (b < 0) b = 0;
+
+ var g = (num & 0x0000FF) + amt;
+
+ if (g > 255) g = 255;
+ else if (g < 0) g = 0;
+
+ return (usePound?"#":"") + String("000000" + (g | (b << 8) | (r << 16)).toString(16)).slice(-6);
+
+}
+
+module.exports = React.createClass({
+ scrollPosition: 0,
+ preventScrollTrigger: false,
+ gotMorePosts: false,
+ oldScrollHeight: 0,
+ oldZoom: 0,
+ scrolledToNew: false,
+ componentDidMount: function() {
+ var user = UserStore.getCurrentUser();
+ if (user.props && user.props.theme) {
+ utils.changeCss('a.theme', 'color:'+user.props.theme+'; fill:'+user.props.theme+'!important;');
+ utils.changeCss('div.theme', 'background-color:'+user.props.theme+';');
+ utils.changeCss('.btn.btn-primary', 'background: ' + user.props.theme+';');
+ utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + changeColor(user.props.theme, -10) +';');
+ utils.changeCss('.modal .modal-header', 'background: ' + user.props.theme+';');
+ utils.changeCss('.mention', 'background: ' + user.props.theme+';');
+ utils.changeCss('.mention-link', 'color: ' + user.props.theme+';');
+ utils.changeCss('@media(max-width: 768px){.search-bar__container', 'background: ' + user.props.theme+';}');
+ }
+
+ PostStore.addChangeListener(this._onChange);
+ ChannelStore.addChangeListener(this._onChange);
+ SocketStore.addChangeListener(this._onSocketChange);
+
+ $(".post-list-holder-by-time").perfectScrollbar();
+
+ this.resize();
+
+ var post_holder = $(".post-list-holder-by-time")[0];
+ this.scrollPosition = $(post_holder).scrollTop() + $(post_holder).innerHeight();
+ this.oldScrollHeight = post_holder.scrollHeight;
+ this.oldZoom = (window.outerWidth - 8) / window.innerWidth;
+
+ var self = this;
+ $(window).resize(function(){
+ $(post_holder).perfectScrollbar('update');
+
+ // this only kind of works, detecting zoom in browsers is a nightmare
+ var newZoom = (window.outerWidth - 8) / window.innerWidth;
+
+ if (self.scrollPosition >= post_holder.scrollHeight || (self.oldScrollHeight != post_holder.scrollHeight && self.scrollPosition >= self.oldScrollHeight) || self.oldZoom != newZoom) self.resize();
+
+ self.oldZoom = newZoom;
+
+ if ($('#create_post').length > 0) {
+ var height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50;
+ $(".post-list-holder-by-time").css("height", height + "px");
+ }
+ });
+
+ $(post_holder).scroll(function(e){
+ if (!self.preventScrollTrigger) {
+ self.scrollPosition = $(post_holder).scrollTop() + $(post_holder).innerHeight();
+ }
+ self.preventScrollTrigger = false;
+ });
+
+ $('body').on('click.userpopover', function(e){
+ if ($(e.target).attr('data-toggle') !== 'popover'
+ && $(e.target).parents('.popover.in').length === 0) {
+ $('.user-popover').popover('hide');
+ }
+ });
+
+ $('.post-list__content div .post').removeClass('post--last');
+ $('.post-list__content div:last-child .post').addClass('post--last');
+
+ $('body').on('mouseenter mouseleave', '.post', function(ev){
+ if(ev.type === 'mouseenter'){
+ $(this).parent('div').prev('.date-seperator').addClass('hovered--after');
+ $(this).parent('div').next('.date-seperator').addClass('hovered--before');
+ }
+ else {
+ $(this).parent('div').prev('.date-seperator').removeClass('hovered--after');
+ $(this).parent('div').next('.date-seperator').removeClass('hovered--before');
+ }
+ });
+
+ },
+ componentDidUpdate: function() {
+ this.resize();
+ var post_holder = $(".post-list-holder-by-time")[0];
+ this.scrollPosition = $(post_holder).scrollTop() + $(post_holder).innerHeight();
+ this.oldScrollHeight = post_holder.scrollHeight;
+ $('.post-list__content div .post').removeClass('post--last');
+ $('.post-list__content div:last-child .post').addClass('post--last');
+ },
+ componentWillUnmount: function() {
+ PostStore.removeChangeListener(this._onChange);
+ ChannelStore.removeChangeListener(this._onChange);
+ SocketStore.removeChangeListener(this._onSocketChange);
+ $('body').off('click.userpopover');
+ },
+ resize: function() {
+ if (this.gotMorePosts) {
+ this.gotMorePosts = false;
+ var post_holder = $(".post-list-holder-by-time")[0];
+ this.preventScrollTrigger = true;
+ $(post_holder).scrollTop($(post_holder).scrollTop() + (post_holder.scrollHeight-this.oldScrollHeight) );
+ $(post_holder).perfectScrollbar('update');
+ } else {
+ var post_holder = $(".post-list-holder-by-time")[0];
+ this.preventScrollTrigger = true;
+ if ($("#new_message")[0] && !this.scrolledToNew) {
+ $(post_holder).scrollTop($(post_holder).scrollTop() + $("#new_message").offset().top - 63);
+ $(post_holder).perfectScrollbar('update');
+ this.scrolledToNew = true;
+ } else {
+ $(post_holder).scrollTop(post_holder.scrollHeight);
+ $(post_holder).perfectScrollbar('update');
+ }
+ }
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+
+ if (!utils.areStatesEqual(newState, this.state)) {
+ if (this.state.post_list && this.state.post_list.order) {
+ if (this.state.channel.id === newState.channel.id && this.state.post_list.order.length != newState.post_list.order.length && newState.post_list.order.length > Constants.POST_CHUNK_SIZE) {
+ this.gotMorePosts = true;
+ }
+ }
+ if (this.state.channel.id !== newState.channel.id) {
+ this.scrolledToNew = false;
+ }
+ this.setState(newState);
+ }
+ },
+ _onSocketChange: function(msg) {
+ if (msg.action == "posted") {
+ var post = JSON.parse(msg.props.post);
+
+ var post_list = PostStore.getPosts(msg.channel_id);
+ if (!post_list) return;
+
+ post_list.posts[post.id] = post;
+ if (post_list.order.indexOf(post.id) === -1) {
+ post_list.order.unshift(post.id);
+ }
+
+ if (this.state.channel.id === msg.channel_id) {
+ this.setState({ post_list: post_list });
+ };
+
+ PostStore.storePosts(post.channel_id, post_list);
+ } else if (msg.action == "post_edited") {
+ if (this.state.channel.id == msg.channel_id) {
+ var post_list = this.state.post_list;
+ if (!(msg.props.post_id in post_list.posts)) return;
+
+ var post = post_list.posts[msg.props.post_id];
+ post.message = msg.props.message;
+
+ post_list.posts[post.id] = post;
+ this.setState({ post_list: post_list });
+
+ PostStore.storePosts(msg.channel_id, post_list);
+ } else {
+ AsyncClient.getPosts(true, msg.channel_id);
+ }
+ } else if (msg.action == "post_deleted") {
+ var activeRoot = $(document.activeElement).closest('.comment-create-body')[0];
+ var activeRootPostId = activeRoot && activeRoot.id.length > 0 ? activeRoot.id : "";
+
+ if (this.state.channel.id == msg.channel_id) {
+ var post_list = this.state.post_list;
+ if (!(msg.props.post_id in this.state.post_list.posts)) return;
+
+ delete post_list.posts[msg.props.post_id];
+ var index = post_list.order.indexOf(msg.props.post_id);
+ if (index > -1) post_list.order.splice(index, 1);
+
+ var scrollSave = $(".post-list-holder-by-time").scrollTop();
+
+ this.setState({ post_list: post_list });
+
+ $(".post-list-holder-by-time").scrollTop(scrollSave)
+
+ PostStore.storePosts(msg.channel_id, post_list);
+ } else {
+ AsyncClient.getPosts(true, msg.channel_id);
+ }
+
+ if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() != msg.user_id) {
+ $('#post_deleted').modal('show');
+ }
+ } else if(msg.action == "new_user") {
+ AsyncClient.getProfiles();
+ }
+ },
+ getMorePosts: function(e) {
+ e.preventDefault();
+
+ if (!this.state.post_list) return;
+
+ var posts = this.state.post_list.posts;
+ var order = this.state.post_list.order;
+ var channel_id = this.state.channel.id;
+
+ $(this.refs.loadmore.getDOMNode()).text("Retrieving more messages...");
+
+ var self = this;
+ var currentPos = $(".post-list").scrollTop;
+
+ Client.getPosts(
+ channel_id,
+ order.length,
+ Constants.POST_CHUNK_SIZE,
+ function(data) {
+ $(self.refs.loadmore.getDOMNode()).text("Load more messages");
+
+ if (!data) return;
+
+ if (data.order.length === 0) return;
+
+ var post_list = {}
+ post_list.posts = $.extend(posts, data.posts);
+ post_list.order = order.concat(data.order);
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POSTS,
+ id: channel_id,
+ post_list: post_list
+ });
+
+ Client.getProfiles();
+ $(".post-list").scrollTop(currentPos);
+ },
+ function(err) {
+ $(self.refs.loadmore.getDOMNode()).text("Load more messages");
+ dispatchError(err, "getPosts");
+ }
+ );
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var order = [];
+ var posts = {};
+
+ var last_viewed = Number.MAX_VALUE;
+
+ if (ChannelStore.getCurrentMember() != null)
+ last_viewed = ChannelStore.getCurrentMember().last_viewed_at;
+
+ if (this.state.post_list != null) {
+ posts = this.state.post_list.posts;
+ order = this.state.post_list.order;
+ }
+
+ var rendered_last_viewed = false;
+
+ var user_id = "";
+ if (UserStore.getCurrentId()) {
+ user_id = UserStore.getCurrentId();
+ } else {
+ return <div/>;
+ }
+
+ var channel = this.state.channel;
+
+ var more_messages = <p className="beginning-messages-text">Beginning of Channel</p>;
+
+ if (channel != null) {
+ if (order.length > 0 && order.length % Constants.POST_CHUNK_SIZE === 0) {
+ more_messages = <a ref="loadmore" className="more-messages-text theme" href="#" onClick={this.getMorePosts}>Load more messages</a>;
+ } else if (channel.type === 'D') {
+ var userIds = channel.name.split('__');
+ var teammate;
+ if (userIds.length === 2 && userIds[0] === user_id) {
+ teammate = UserStore.getProfile(userIds[1]);
+ } else if (userIds.length === 2 && userIds[1] === user_id) {
+ teammate = UserStore.getProfile(userIds[0]);
+ }
+
+ if (teammate) {
+ var teammate_name = teammate.full_name.length > 0 ? teammate.full_name : teammate.username;
+ more_messages = (
+ <div className="channel-intro">
+ <div className="post-profile-img__container channel-intro-img">
+ <img className="post-profile-img" src={"/api/v1/users/" + teammate.id + "/image"} height="50" width="50" />
+ </div>
+ <div className="channel-intro-profile">
+ <strong><UserProfile userId={teammate.id} /></strong>
+ </div>
+ <p className="channel-intro-text">{"This is the start of your direct message history with " + teammate_name + "." }<br/>{"Direct messages and files shared here are not shown to people outside this area."}</p>
+ </div>
+ );
+ } else {
+ more_messages = (
+ <div className="channel-intro">
+ <p className="channel-intro-text">{"This is the start of your direct message history with this " + strings.Team + "mate. Direct messages and files shared here are not shown to people outside this area."}</p>
+ </div>
+ );
+ }
+ } else if (channel.type === 'P' || channel.type === 'O') {
+ var ui_name = channel.display_name
+ var members = ChannelStore.getCurrentExtraInfo().members;
+ var creator_name = "";
+
+ for (var i = 0; i < members.length; i++) {
+ if (members[i].roles.indexOf('admin') > -1) {
+ creator_name = members[i].username;
+ break;
+ }
+ }
+
+ if (channel.name === Constants.DEFAULT_CHANNEL) {
+ more_messages = (
+ <div className="channel-intro">
+ <h4 className="channel-intro-title">Welcome</h4>
+ <p>
+ Welcome to {ui_name}!
+ <br/><br/>
+ {"This is the first channel " + strings.Team + "mates see when they"}
+ <br/>
+ sign up - use it for posting updates everyone needs to know.
+ <br/><br/>
+ To create a new channel or join an existing one, go to
+ <br/>
+ the Left Hand Sidebar under “Channels” and click “More…”.
+ <br/>
+ </p>
+ </div>
+ );
+ } else {
+ var userStyle = { color: UserStore.getCurrentUser().props.theme }
+ var ui_type = channel.type === 'P' ? "private group" : "channel";
+ more_messages = (
+ <div className="channel-intro">
+ <h4 className="channel-intro-title">Welcome</h4>
+ <p>
+ { creator_name != "" ? "This is the start of the " + ui_name + " " + ui_type + ", created by " + creator_name + " on " + utils.displayDate(channel.create_at) + "."
+ : "This is the start of the " + ui_name + " " + ui_type + ", created on "+ utils.displayDate(channel.create_at) + "." }
+ { channel.type === 'P' ? " Only invited members can see this private group." : " Any member can join and read this channel." }
+ <br/>
+ <a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={channel.display_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
+ <a className="intro-links" style={userStyle} data-toggle="modal" data-target="#channel_invite"><i className="fa fa-user-plus"></i>Invite others to this {ui_type}</a>
+ </p>
+ </div>
+ );
+ }
+ }
+ }
+
+ var postCtls = [];
+ var previousPostDay = posts[order[order.length-1]] ? utils.getDateForUnixTicks(posts[order[order.length-1]].create_at): new Date();
+ var currentPostDay = new Date();
+
+ for (var i = order.length-1; i >= 0; i--) {
+ var post = posts[order[i]];
+ var parentPost;
+
+ if (post.parent_id) {
+ parentPost = posts[post.parent_id];
+ } else {
+ parentPost = null;
+ }
+
+ var sameUser = i < order.length-1 && posts[order[i+1]].user_id === post.user_id && post.create_at - posts[order[i+1]].create_at <= 1000*60*5 ? "same--user" : "";
+ var sameRoot = i < order.length-1 && post.root_id != "" && (posts[order[i+1]].id === post.root_id || posts[order[i+1]].root_id === post.root_id) ? true : false;
+
+ // we only hide the profile pic if the previous post is not a comment, the current post is not a comment, and the previous post was made by the same user as the current post
+ var hideProfilePic = i < order.length-1 && posts[order[i+1]].user_id === post.user_id && posts[order[i+1]].root_id === '' && post.root_id === '';
+
+ // check if it's the last comment in a consecutive string of comments on the same post
+ var isLastComment = false;
+ if (utils.isComment(post)) {
+ // it is the last comment if it is last post in the channel or the next post has a different root post
+ isLastComment = (i === 0 || posts[order[i-1]].root_id != post.root_id);
+ }
+
+ var postCtl = <Post sameUser={sameUser} sameRoot={sameRoot} post={post} parentPost={parentPost} key={post.id} posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} />;
+
+ currentPostDay = utils.getDateForUnixTicks(post.create_at);
+ if(currentPostDay.getDate() !== previousPostDay.getDate() || currentPostDay.getMonth() !== previousPostDay.getMonth() || currentPostDay.getFullYear() !== previousPostDay.getFullYear()) {
+ postCtls.push(
+ <div className="date-seperator">
+ <hr className="date-seperator__hr" />
+ <div className="date-seperator__text">{currentPostDay.toDateString()}</div>
+ </div>
+ );
+ }
+
+ if (post.create_at > last_viewed && !rendered_last_viewed) {
+ rendered_last_viewed = true;
+ postCtls.push(
+ <div>
+ <div className="new-seperator">
+ <hr id="new_message" className="new-seperator__hr" />
+ <div className="new-seperator__text">New Messages</div>
+ </div>
+ {postCtl}
+ </div>
+ );
+ } else {
+ postCtls.push(postCtl);
+ }
+ previousPostDay = utils.getDateForUnixTicks(post.create_at);
+ }
+
+ return (
+ <div ref="postlist" className="post-list-holder-by-time">
+ <div className="post-list__table">
+ <div className="post-list__content">
+ { more_messages }
+ { postCtls }
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
+
diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx
new file mode 100644
index 000000000..43be60afa
--- /dev/null
+++ b/web/react/components/post_right.jsx
@@ -0,0 +1,397 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PostStore = require('../stores/post_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserProfile = require( './user_profile.jsx' );
+var UserStore = require('../stores/user_store.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var utils = require('../utils/utils.jsx');
+var SearchBox =require('./search_bar.jsx');
+var CreateComment = require( './create_comment.jsx' );
+var Constants = require('../utils/constants.jsx');
+var ViewImageModal = require('./view_image.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+RhsHeaderPost = React.createClass({
+ handleClose: function(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ },
+ handleBack: function(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH_TERM,
+ term: this.props.fromSearch,
+ do_search: true,
+ is_mention_search: this.props.isMentionSearch
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ },
+ render: function() {
+ var back = this.props.fromSearch ? <a href="#" onClick={this.handleBack} style={{color:"black"}}>{"< "}</a> : "";
+
+ return (
+ <div className="sidebar--right__header">
+ <span className="sidebar--right__title">{back}Message Details</span>
+ <button type="button" className="sidebar--right__close" aria-label="Close" onClick={this.handleClose}></button>
+ </div>
+ );
+ }
+});
+
+RootPost = React.createClass({
+ handleImageClick: function(e) {
+ this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))});
+ },
+ getInitialState: function() {
+ return { startImgId: 0 };
+ },
+ render: function() {
+
+ var postImageModalId = "rhs_view_image_modal_" + this.props.post.id;
+ var message = utils.textToJsx(this.props.post.message);
+ var filenames = this.props.post.filenames;
+ var isOwner = UserStore.getCurrentId() == this.props.post.user_id;
+
+ var type = "Post"
+ if (this.props.post.root_id.length > 0) {
+ type = "Comment"
+ }
+
+ if (filenames) {
+ var postFiles = [];
+ var images = [];
+ var re1 = new RegExp(' ', 'g');
+ var re2 = new RegExp('\\(', 'g');
+ var re3 = new RegExp('\\)', 'g');
+ for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
+ var fileSplit = filenames[i].split('.');
+ if (fileSplit.length < 2) continue;
+
+ var ext = fileSplit[fileSplit.length-1];
+ fileSplit.splice(fileSplit.length-1,1)
+ var filePath = fileSplit.join('.');
+ var filename = filePath.split('/')[filePath.split('/').length-1];
+
+ var ftype = utils.getFileType(ext);
+
+ if (ftype === "image") {
+ var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
+ postFiles.push(
+ <div className="post-image__column" key={filePath}>
+ <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filePath} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
+ </div>
+ );
+ images.push(filenames[i]);
+ } else {
+ postFiles.push(
+ <div className="post-image__column custom-file" key={filePath}>
+ <a href={filePath+"."+ext} download={filename+"."+ext}>
+ <div className={"file-icon "+utils.getIconClassName(ftype)}/>
+ </a>
+ </div>
+ );
+ }
+ }
+ }
+
+ return (
+ <div className="post post--root">
+ <div className="post-profile-img__container">
+ <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image"} height="36" width="36" />
+ </div>
+ <div className="post__content">
+ <ul className="post-header">
+ <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li>
+ <li className="post-header-col"><time className="post-right-root-time">{ utils.displayDate(this.props.post.create_at)+' '+utils.displayTime(this.props.post.create_at) }</time></li>
+ <li className="post-header-col post-header__reply">
+ <div className="dropdown">
+ { isOwner ?
+ <div>
+ <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false">
+ [...]
+ </a>
+ <ul className="dropdown-menu" role="menu">
+ <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li>
+ <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={this.props.commentCount}>Delete</a></li>
+ </ul>
+ </div>
+ : "" }
+ </div>
+ </li>
+ </ul>
+ <div className="post-body">
+ <p>{message}</p>
+ { filenames.length > 0 ?
+ <div className="post-image__columns">
+ { postFiles }
+ </div>
+ : "" }
+ { images.length > 0 ?
+ <ViewImageModal
+ channelId={this.props.post.channel_id}
+ userId={this.props.post.user_id}
+ modalId={postImageModalId}
+ startId={this.state.startImgId}
+ imgCount={this.props.post.img_count}
+ filenames={images} />
+ : "" }
+ </div>
+ </div>
+ <hr />
+ </div>
+ );
+ }
+});
+
+CommentPost = React.createClass({
+ handleImageClick: function(e) {
+ this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))});
+ },
+ getInitialState: function() {
+ return { startImgId: 0 };
+ },
+ render: function() {
+
+ var commentClass = "post";
+
+ var postImageModalId = "rhs_comment_view_image_modal_" + this.props.post.id;
+ var filenames = this.props.post.filenames;
+ var isOwner = UserStore.getCurrentId() == this.props.post.user_id;
+
+ var type = "Post"
+ if (this.props.post.root_id.length > 0) {
+ type = "Comment"
+ }
+
+ if (filenames) {
+ var postFiles = [];
+ var images = [];
+ var re1 = new RegExp(' ', 'g');
+ var re2 = new RegExp('\\(', 'g');
+ var re3 = new RegExp('\\)', 'g');
+ for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
+ var fileSplit = filenames[i].split('.');
+ if (fileSplit.length < 2) continue;
+
+ var ext = fileSplit[fileSplit.length-1];
+ fileSplit.splice(fileSplit.length-1,1)
+ var filePath = fileSplit.join('.');
+ var filename = filePath.split('/')[filePath.split('/').length-1];
+
+ var type = utils.getFileType(ext);
+
+ if (type === "image") {
+ var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
+ postFiles.push(
+ <div className="post-image__column" key={filename}>
+ <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filename} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
+ </div>
+ );
+ images.push(filenames[i]);
+ } else {
+ postFiles.push(
+ <div className="post-image__column custom-file" key={filename}>
+ <a href={filePath+"."+ext} download={filename+"."+ext}>
+ <div className={"file-icon "+utils.getIconClassName(type)}/>
+ </a>
+ </div>
+ );
+ }
+ }
+ }
+
+ var message = utils.textToJsx(this.props.post.message);
+
+ return (
+ <div className={commentClass}>
+ <div className="post-profile-img__container">
+ <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image"} height="36" width="36" />
+ </div>
+ <div className="post__content">
+ <ul className="post-header">
+ <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li>
+ <li className="post-header-col"><time className="post-right-comment-time">{ utils.displayDateTime(this.props.post.create_at) }</time></li>
+ <li className="post-header-col post-header__reply">
+ { isOwner ?
+ <div className="dropdown" onClick={function(e){$('.post-list-holder-by-time').scrollTop($(".post-list-holder-by-time").scrollTop() + 50);}}>
+ <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false">
+ [...]
+ </a>
+ <ul className="dropdown-menu" role="menu">
+ <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li>
+ <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={0}>Delete</a></li>
+ </ul>
+ </div>
+ : "" }
+ </li>
+ </ul>
+ <div className="post-body">
+ <p>{message}</p>
+ { filenames.length > 0 ?
+ <div className="post-image__columns">
+ { postFiles }
+ </div>
+ : "" }
+ { images.length > 0 ?
+ <ViewImageModal
+ channelId={this.props.post.channel_id}
+ userId={this.props.post.user_id}
+ modalId={postImageModalId}
+ startId={this.state.startImgId}
+ imgCount={this.props.post.img_count}
+ filenames={images} />
+ : "" }
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
+function getStateFromStores() {
+ return { post_list: PostStore.getSelectedPost() };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ PostStore.addSelectedPostChangeListener(this._onChange);
+ PostStore.addChangeListener(this._onChangeAll);
+ $(".post-right__scroll").perfectScrollbar();
+ this.resize();
+ var self = this;
+ $(window).resize(function(){
+ self.resize();
+ });
+ },
+ componentDidUpdate: function() {
+ this.resize();
+ },
+ componentWillUnmount: function() {
+ PostStore.removeSelectedPostChangeListener(this._onChange);
+ PostStore.removeChangeListener(this._onChangeAll);
+ },
+ _onChange: function() {
+ if (this.isMounted()) {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ _onChangeAll: function() {
+
+ if (this.isMounted()) {
+
+ // if something was changed in the channel like adding a
+ // comment or post then lets refresh the sidebar list
+ var currentSelected = PostStore.getSelectedPost();
+ if (!currentSelected || currentSelected.order.length == 0) {
+ return;
+ }
+
+ var currentPosts = PostStore.getPosts(currentSelected.posts[currentSelected.order[0]].channel_id);
+
+ if (!currentPosts || currentPosts.order.length == 0) {
+ return;
+ }
+
+
+ if (currentPosts.posts[currentPosts.order[0]].channel_id == currentSelected.posts[currentSelected.order[0]].channel_id) {
+ currentSelected.posts = {};
+ for (var postId in currentPosts.posts) {
+ currentSelected.posts[postId] = currentPosts.posts[postId];
+ }
+
+ PostStore.storeSelectedPost(currentSelected);
+ }
+
+ this.setState(getStateFromStores());
+ }
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ resize: function() {
+ var height = $(window).height() - $('#error_bar').outerHeight() - 100;
+ $(".post-right__scroll").css("height", height + "px");
+ $(".post-right__scroll").scrollTop(100000);
+ $(".post-right__scroll").perfectScrollbar('update');
+ },
+ render: function() {
+
+ var post_list = this.state.post_list;
+
+ if (post_list == null) {
+ return (
+ <div></div>
+ );
+ }
+
+ var selected_post = post_list.posts[post_list.order[0]];
+ var root_post = null;
+
+ if (selected_post.root_id == "") {
+ root_post = selected_post;
+ }
+ else {
+ root_post = post_list.posts[selected_post.root_id];
+ }
+
+ var posts_array = [];
+
+ for (var postId in post_list.posts) {
+ var cpost = post_list.posts[postId];
+ if (cpost.root_id == root_post.id) {
+ posts_array.push(cpost);
+ }
+ }
+
+ posts_array.sort(function(a,b) {
+ if (a.create_at < b.create_at)
+ return -1;
+ if (a.create_at > b.create_at)
+ return 1;
+ return 0;
+ });
+
+ var results = this.state.results;
+ var currentId = UserStore.getCurrentId();
+ var searchForm = currentId == null ? null : <SearchBox />;
+
+ return (
+ <div className="post-right__container">
+ <div className="search-bar__container sidebar--right__search-header">{searchForm}</div>
+ <div className="sidebar-right__body">
+ <RhsHeaderPost fromSearch={this.props.fromSearch} isMentionSearch={this.props.isMentionSearch} />
+ <div className="post-right__scroll">
+ <RootPost post={root_post} commentCount={posts_array.length}/>
+ <div className="post-right-comments-container">
+ { posts_array.map(function(cpost) {
+ return <CommentPost key={cpost.id} post={cpost} selected={ (cpost.id == selected_post.id) } />
+ })}
+ </div>
+ <div className="post-create__container">
+ <CreateComment channelId={root_post.channel_id} rootId={root_post.id} />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/rename_channel_modal.jsx b/web/react/components/rename_channel_modal.jsx
new file mode 100644
index 000000000..b4ccb2937
--- /dev/null
+++ b/web/react/components/rename_channel_modal.jsx
@@ -0,0 +1,142 @@
+// 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 ChannelStore = require('../stores/channel_store.jsx');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ if (this.state.channel_id.length !== 26) return;
+
+ var channel = ChannelStore.get(this.state.channel_id);
+ var oldName = channel.name
+ var oldDisplayName = channel.display_name
+ var state = { server_error: "" };
+
+ channel.display_name = this.state.display_name.trim();
+ if (!channel.display_name) {
+ state.display_name_error = "This field is required";
+ state.inValid = true;
+ }
+ else if (channel.display_name.length > 22) {
+ state.display_name_error = "This field must be less than 22 characters";
+ state.inValid = true;
+ }
+ else {
+ state.display_name_error = "";
+ }
+
+ channel.name = this.state.channel_name.trim();
+ if (!channel.name) {
+ state.name_error = "This field is required";
+ state.inValid = true;
+ }
+ else if(channel.name.length > 22){
+ state.name_error = "This field must be less than 22 characters";
+ state.inValid = true;
+ }
+ else {
+ var cleaned_name = utils.cleanUpUrlable(channel.name);
+ if (cleaned_name != channel.name) {
+ state.name_error = "Must be lowercase alphanumeric characters";
+ state.inValid = true;
+ }
+ else {
+ state.name_error = "";
+ }
+ }
+
+ this.setState(state);
+
+ if (state.inValid)
+ return;
+
+ if (oldName == channel.name && oldDisplayName == channel.display_name)
+ return;
+
+ Client.updateChannel(channel,
+ function(data) {
+ this.refs.display_name.getDOMNode().value = "";
+ this.refs.channel_name.getDOMNode().value = "";
+
+ $('#' + this.props.modalId).modal('hide');
+ window.location.href = '/channels/' + this.state.channel_name;
+ AsyncClient.getChannels(true);
+ }.bind(this),
+ function(err) {
+ state.server_error = err.message;
+ state.inValid = true;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ onNameChange: function() {
+ this.setState({ channel_name: this.refs.channel_name.getDOMNode().value })
+ },
+ onDisplayNameChange: function() {
+ this.setState({ display_name: this.refs.display_name.getDOMNode().value })
+ },
+ displayNameKeyUp: function(e) {
+ var display_name = this.refs.display_name.getDOMNode().value.trim();
+ var channel_name = utils.cleanUpUrlable(display_name);
+ this.refs.channel_name.getDOMNode().value = channel_name;
+ this.setState({ channel_name: channel_name })
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ var button = $(e.relatedTarget);
+ self.setState({ display_name: button.attr('data-display'), title: button.attr('data-name'), channel_id: button.attr('data-channelid') });
+ });
+ },
+ getInitialState: function() {
+ return { display_name: "", channel_name: "", channel_id: "" };
+ },
+ render: function() {
+
+ var display_name_error = this.state.display_name_error ? <label className='control-label'>{ this.state.display_name_error }</label> : null;
+ var name_error = this.state.name_error ? <label className='control-label'>{ this.state.name_error }</label> : null;
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ return (
+ <div className="modal fade" ref="modal" id="rename_channel" 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">Close</span>
+ </button>
+ <h4 className="modal-title">Rename Channel</h4>
+ </div>
+ <div className="modal-body">
+ <form role="form">
+ <div className={ this.state.display_name_error ? "form-group has-error" : "form-group" }>
+ <label className='control-label'>Display Name</label>
+ <input onKeyUp={this.displayNameKeyUp} onChange={this.onDisplayNameChange} type="text" ref="display_name" className="form-control" placeholder="Enter display name" value={this.state.display_name} maxLength="64" />
+ { display_name_error }
+ </div>
+ <div className={ this.state.name_error ? "form-group has-error" : "form-group" }>
+ <label className='control-label'>Handle</label>
+ <input onChange={this.onNameChange} type="text" className="form-control" ref="channel_name" placeholder="lowercase alphanumeric's only" value={this.state.channel_name} maxLength="64" />
+ { name_error }
+ </div>
+ { server_error }
+ </form>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Save</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
diff --git a/web/react/components/rename_team_modal.jsx b/web/react/components/rename_team_modal.jsx
new file mode 100644
index 000000000..67a150b9d
--- /dev/null
+++ b/web/react/components/rename_team_modal.jsx
@@ -0,0 +1,92 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ var state = { server_error: "" };
+ var valid = true;
+
+ var name = this.state.name.trim();
+ if (!name) {
+ state.name_error = "This field is required";
+ valid = false;
+ } else {
+ state.name_error = "";
+ }
+
+ this.setState(state);
+
+ if (!valid)
+ return;
+
+ if (this.props.teamName === name)
+ return;
+
+ var data = {};
+ data["new_name"] = name;
+
+ Client.updateTeamName(data,
+ function(data) {
+ $('#rename_team_link').modal('hide');
+ window.location.reload();
+ }.bind(this),
+ function(err) {
+ state.server_error = err.message;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ onNameChange: function() {
+ this.setState({ name: this.refs.name.getDOMNode().value })
+ },
+ componentDidMount: function() {
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
+ self.setState({ name: self.props.teamName });
+ });
+ },
+ getInitialState: function() {
+ return { name: this.props.teamName };
+ },
+ render: function() {
+
+ var name_error = this.state.name_error ? <label className='control-label'>{ this.state.name_error }</label> : null;
+ var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ return (
+ <div className="modal fade" ref="modal" id="rename_team_link" 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">Close</span>
+ </button>
+ <h4 className="modal-title">{"Rename " + utils.toTitleCase(strings.Team)}</h4>
+ </div>
+ <div className="modal-body">
+ <form role="form" onSubmit={this.handleSubmit}>
+ <div className={ this.state.name_error ? "form-group has-error" : "form-group" }>
+ <label className='control-label'>Name</label>
+ <input onChange={this.onNameChange} type="text" ref="name" className="form-control" placeholder={"Enter "+strings.Team+" name"} value={this.state.name} maxLength="64" />
+ { name_error }
+ </div>
+ { server_error }
+ </form>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Save</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx
new file mode 100644
index 000000000..cddb738f9
--- /dev/null
+++ b/web/react/components/search_bar.jsx
@@ -0,0 +1,104 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var client = require('../utils/client.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var utils = require('../utils/utils.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+function getSearchTermStateFromStores() {
+ term = PostStore.getSearchTerm();
+ if (!term) term = "";
+ return {
+ search_term: term
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ PostStore.addSearchTermChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ PostStore.removeSearchTermChangeListener(this._onChange);
+ },
+ _onChange: function(doSearch, isMentionSearch) {
+ if (this.isMounted()) {
+ var newState = getSearchTermStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ if (doSearch) {
+ this.performSearch(newState.search_term, isMentionSearch);
+ }
+ }
+ },
+ handleClose: function(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ },
+ handleUserInput: function(e) {
+ var term = e.target.value;
+ PostStore.storeSearchTerm(term);
+ PostStore.emitSearchTermChange(false);
+ this.setState({ search_term: term });
+ },
+ handleUserFocus: function(e) {
+ e.target.select();
+ },
+ performSearch: function(terms, isMentionSearch) {
+ if (terms.length > 0) {
+ $("#search-spinner").removeClass("hidden");
+ client.search(
+ terms,
+ function(data) {
+ $("#search-spinner").addClass("hidden");
+ if(utils.isMobile()) {
+ $('#search')[0].value = "";
+ }
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: data,
+ is_mention_search: isMentionSearch
+ });
+ },
+ function(err) {
+ $("#search-spinner").addClass("hidden");
+ dispatchError(err, "search");
+ }
+ );
+ }
+ },
+ handleSubmit: function(e) {
+ e.preventDefault();
+ terms = this.state.search_term.trim();
+ this.performSearch(terms);
+ },
+ getInitialState: function() {
+ return getSearchTermStateFromStores();
+ },
+ render: function() {
+ return (
+ <div>
+ <div className="sidebar__collapse" onClick={this.handleClose}></div>
+ <span className="glyphicon glyphicon-search sidebar__search-icon"></span>
+ <form role="form" className="search__form relative-div" onSubmit={this.handleSubmit}>
+ <input type="text" className="form-control search-bar-box" ref="search" id="search" placeholder="Search" value={this.state.search_term} onFocus={this.handleUserFocus} onChange={this.handleUserInput} />
+ <span id="search-spinner" className="glyphicon glyphicon-refresh glyphicon-refresh-animate hidden"></span>
+ </form>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx
new file mode 100644
index 000000000..51aefd3b8
--- /dev/null
+++ b/web/react/components/search_results.jsx
@@ -0,0 +1,180 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var PostStore = require('../stores/post_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var UserProfile = require( './user_profile.jsx' );
+var SearchBox =require('./search_bar.jsx');
+var utils = require('../utils/utils.jsx');
+var client =require('../utils/client.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+RhsHeaderSearch = React.createClass({
+ handleClose: function(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ },
+ render: function() {
+ var title = this.props.isMentionSearch ? "Recent Mentions" : "Search Results";
+ return (
+ <div className="sidebar--right__header">
+ <span className="sidebar--right__title">{title}</span>
+ <button type="button" className="sidebar--right__close" aria-label="Close" onClick={this.handleClose}></button>
+ </div>
+ );
+ }
+});
+
+SearchItem = React.createClass({
+ handleClick: function(e) {
+ e.preventDefault();
+
+ var self = this;
+ client.getPost(
+ this.props.post.channel_id,
+ this.props.post.id,
+ function(data) {
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ post_list: data,
+ from_search: PostStore.getSearchTerm()
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null,
+ is_mention_search: self.props.isMentionSearch
+ });
+ },
+ function(err) {
+ dispatchError(err, "getPost");
+ }
+ );
+ },
+ render: function() {
+
+ 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)
+
+ if (channel) {
+ if (channel.type === 'D') {
+ channelName = "Direct Message";
+ } else {
+ channelName = channel.display_name;
+ }
+ }
+
+ return (
+ <div className="search-item-container post" onClick={this.handleClick}>
+ <div className="search-channel__name">{ channelName }</div>
+ <div className="post-profile-img__container">
+ <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image"} height="36" width="36" />
+ </div>
+ <div className="post__content">
+ <ul className="post-header">
+ <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li>
+ <li className="post-header-col"><time className="search-item-time">{ utils.displayDate(this.props.post.create_at)+' '+utils.displayTime(this.props.post.create_at) }</time></li>
+ </ul>
+ <div className="search-item-snippet"><span>{message}</span></div>
+ </div>
+ </div>
+ );
+ }
+});
+
+function getStateFromStores() {
+ return { results: PostStore.getSearchResults() };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ PostStore.addSearchChangeListener(this._onChange);
+ this.resize();
+ var self = this;
+ $(window).resize(function(){
+ self.resize();
+ });
+ },
+ componentDidUpdate: function() {
+ this.resize();
+ },
+ componentWillUnmount: function() {
+ PostStore.removeSearchChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ if (this.isMounted()) {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ resize: function() {
+ var height = $(window).height() - $('#error_bar').outerHeight() - 100;
+ $("#search-items-container").css("height", height + "px");
+ $("#search-items-container").scrollTop(0);
+ $("#search-items-container").perfectScrollbar();
+ },
+ render: function() {
+
+ var results = this.state.results;
+ var currentId = UserStore.getCurrentId();
+ var searchForm = currentId == null ? null : <SearchBox />;
+
+ if (results == null) {
+ return (
+ <div className="sidebar--right__header">
+ <div className="sidebar__heading">Search Results</div>
+ </div>
+ );
+ }
+
+ if (!results.order || results.order.length == 0) {
+ return (
+ <div className="sidebar--right__content">
+ <div className="search-bar__container">{searchForm}</div>
+ <div className="sidebar-right__body">
+ <RhsHeaderSearch />
+ <div id="search-items-container" className="search-items-container">
+ <div className="sidebar--right__subheader">No results</div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ var self = this;
+ return (
+ <div className="sidebar--right__content">
+ <div className="search-bar__container sidebar--right__search-header">{searchForm}</div>
+ <div className="sidebar-right__body">
+ <RhsHeaderSearch isMentionSearch={this.props.isMentionSearch} />
+ <div id="search-items-container" className="search-items-container">
+ {results.order.map(function(id) {
+ var post = results.posts[id];
+ return <SearchItem key={post.id} post={post} term={PostStore.getSearchTerm()} isMentionSearch={self.props.isMentionSearch} />
+ })}
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx
new file mode 100644
index 000000000..03f05b0cf
--- /dev/null
+++ b/web/react/components/setting_item_max.jsx
@@ -0,0 +1,31 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ render: function() {
+ var client_error = this.props.client_error ? <div className='form-group'><label className='col-sm-12 has-error'>{ this.props.client_error }</label></div> : null;
+ var server_error = this.props.server_error ? <div className='form-group'><label className='col-sm-12 has-error'>{ this.props.server_error }</label></div> : null;
+
+ var inputs = this.props.inputs;
+
+ return (
+ <ul className="section-max form-horizontal">
+ <li className="col-sm-12 section-title">{this.props.title}</li>
+ <li className="col-sm-9 col-sm-offset-3">
+ <ul className="setting-list">
+ <li className="row setting-list-item form-group">
+ {inputs}
+ </li>
+ <li className="setting-list-item">
+ <hr />
+ { server_error }
+ { client_error }
+ <a className="btn btn-sm btn-primary" onClick={this.props.submit}>Submit</a>
+ <a className="btn btn-sm theme" href="#" onClick={this.props.updateSection}>Cancel</a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ );
+ }
+});
diff --git a/web/react/components/setting_item_min.jsx b/web/react/components/setting_item_min.jsx
new file mode 100644
index 000000000..2209c74d1
--- /dev/null
+++ b/web/react/components/setting_item_min.jsx
@@ -0,0 +1,14 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ render: function() {
+ return (
+ <ul className="section-min">
+ <li className="col-sm-10 section-title">{this.props.title}</li>
+ <li className="col-sm-2 section-edit"><a className="section-edit theme" href="#" onClick={this.props.updateSection}>Edit</a></li>
+ <li className="col-sm-7 section-describe">{this.props.describe}</li>
+ </ul>
+ );
+ }
+});
diff --git a/web/react/components/setting_picture.jsx b/web/react/components/setting_picture.jsx
new file mode 100644
index 000000000..62c889b7f
--- /dev/null
+++ b/web/react/components/setting_picture.jsx
@@ -0,0 +1,55 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ setPicture: function(file) {
+ if (file) {
+ var reader = new FileReader();
+
+ var img = this.refs.image.getDOMNode();
+ reader.onload = function (e) {
+ $(img).attr('src', e.target.result)
+ };
+
+ reader.readAsDataURL(file);
+ }
+ },
+ componentWillReceiveProps: function(nextProps) {
+ if (nextProps.picture) {
+ this.setPicture(nextProps.picture);
+ }
+ },
+ render: function() {
+ var client_error = this.props.client_error ? <div className='form-group has-error'><label className='control-label'>{ this.props.client_error }</label></div> : null;
+ var server_error = this.props.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.props.server_error }</label></div> : null;
+
+ var img = null;
+ if (this.props.picture) {
+ img = (<img ref="image" className="col-xs-5 profile-img" src=""/>);
+ } else {
+ img = (<img ref="image" className="col-xs-5 profile-img" src={this.props.src}/>);
+ }
+
+ var self = this;
+
+ return (
+ <ul className="section-max">
+ <li className="col-xs-12 section-title">{this.props.title}</li>
+ <li className="col-xs-offset-3 col-xs-8">
+ <ul className="setting-list">
+ <li className="row setting-list-item">
+ {img}
+ </li>
+ <li className="setting-list-item">
+ { server_error }
+ { client_error }
+ <span className="btn btn-sm btn-primary btn-file sel-btn">Upload<input ref="input" accept=".jpg,.png,.bmp" type="file" onChange={this.props.pictureChange}/></span>
+ <a className={this.props.submitActive ? "btn btn-sm btn-primary" : "btn btn-sm btn-inactive disabled"} onClick={this.props.submit}>Save</a>
+ <a className="btn btn-sm theme" href="#" onClick={self.props.updateSection}>Cancel</a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ );
+ }
+});
diff --git a/web/react/components/settings_modal.jsx b/web/react/components/settings_modal.jsx
new file mode 100644
index 000000000..57a869f93
--- /dev/null
+++ b/web/react/components/settings_modal.jsx
@@ -0,0 +1,59 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SettingsSidebar = require('./settings_sidebar.jsx');
+var UserSettings = require('./user_settings.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ $('body').on('click', '.modal-back', function(){
+ $(this).closest('.modal-dialog').removeClass('display--content');
+ });
+ $('body').on('click', '.modal-header .close', function(){
+ setTimeout(function() {
+ $('.modal-dialog.display--content').removeClass('display--content');
+ }, 500);
+ });
+ },
+ updateTab: function(tab) {
+ this.setState({ active_tab: tab });
+ },
+ updateSection: function(section) {
+ this.setState({ active_section: section });
+ },
+ getInitialState: function() {
+ return { active_tab: "general", active_section: "" };
+ },
+ render: function() {
+ return (
+ <div className="modal fade" ref="modal" id="settings_modal" role="dialog" aria-hidden="true">
+ <div className="modal-dialog settings-modal">
+ <div className="modal-content">
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title">Account Settings</h4>
+ </div>
+ <div className="modal-body">
+ <div className="settings-table">
+ <div className="settings-links">
+ <SettingsSidebar
+ activeTab={this.state.active_tab}
+ updateTab={this.updateTab}
+ />
+ </div>
+ <div className="settings-content">
+ <UserSettings
+ activeTab={this.state.active_tab}
+ activeSection={this.state.active_section}
+ updateSection={this.updateSection}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx
new file mode 100644
index 000000000..34e3c9203
--- /dev/null
+++ b/web/react/components/settings_sidebar.jsx
@@ -0,0 +1,24 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+module.exports = React.createClass({
+ updateTab: function(tab) {
+ this.props.updateTab(tab);
+ $('.settings-modal').addClass('display--content');
+ },
+ render: function() {
+ var self = this;
+ return (
+ <div className="">
+ <ul className="nav nav-pills nav-stacked">
+ <li className={this.props.activeTab == 'general' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("general");}}><i className="glyphicon glyphicon-cog"></i>General</a></li>
+ <li className={this.props.activeTab == 'security' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("security");}}><i className="glyphicon glyphicon-lock"></i>Security</a></li>
+ <li className={this.props.activeTab == 'notifications' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("notifications");}}><i className="glyphicon glyphicon-exclamation-sign"></i>Notifications</a></li>
+ <li className={this.props.activeTab == 'sessions' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("sessions");}}><i className="glyphicon glyphicon-globe"></i>Sessions</a></li>
+ <li className={this.props.activeTab == 'activity_log' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("activity_log");}}><i className="glyphicon glyphicon-time"></i>Activity Log</a></li>
+ <li className={this.props.activeTab == 'appearance' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("appearance");}}><i className="glyphicon glyphicon-wrench"></i>Appearance</a></li>
+ </ul>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
new file mode 100644
index 000000000..10017c7ee
--- /dev/null
+++ b/web/react/components/sidebar.jsx
@@ -0,0 +1,449 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+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 ActionTypes = Constants.ActionTypes;
+
+var SidebarLoginForm = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ var state = { }
+
+ var domain = this.refs.domain.getDOMNode().value.trim();
+ if (!domain) {
+ state.server_error = "A domain is required"
+ this.setState(state);
+ return;
+ }
+
+ var email = this.refs.email.getDOMNode().value.trim();
+ if (!email) {
+ state.server_error = "An email is required"
+ this.setState(state);
+ return;
+ }
+
+ var password = this.refs.password.getDOMNode().value.trim();
+ if (!password) {
+ state.server_error = "A password is required"
+ this.setState(state);
+ return;
+ }
+
+ state.server_error = "";
+ this.setState(state);
+
+ client.loginByEmail(domain, email, password,
+ function(data) {
+ UserStore.setLastDomain(domain);
+ UserStore.setLastEmail(email);
+ UserStore.setCurrentUser(data);
+
+ var redirect = utils.getUrlParameter("redirect");
+ if (redirect) {
+ window.location.href = decodeURI(redirect);
+ } else {
+ window.location.href = '/channels/town-square';
+ }
+
+ }.bind(this),
+ function(err) {
+ if (err.message == "Login failed because email address has not been verified") {
+ window.location.href = '/verify?domain=' + encodeURIComponent(domain) + '&email=' + encodeURIComponent(email);
+ return;
+ }
+ state.server_error = err.message;
+ this.valid = false;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <label className="control-label">{this.state.server_error}</label> : null;
+
+ var subDomain = utils.getSubDomain();
+ var subDomainClass = "form-control hidden";
+
+ if (subDomain == "") {
+ subDomain = UserStore.getLastDomain();
+ subDomainClass = "form-control";
+ }
+
+ return (
+ <form className="" onSubmit={this.handleSubmit}>
+ <a href="/find_team">{"Find your " + strings.Team}</a>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ { server_error }
+ <input type="text" className={subDomainClass} name="domain" defaultValue={subDomain} ref="domain" placeholder="Domain" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="text" className="form-control" name="email" defaultValue={UserStore.getLastEmail()} ref="email" placeholder="Email" />
+ </div>
+ <div className={server_error ? 'form-group has-error' : 'form-group'}>
+ <input type="password" className="form-control" name="password" ref="password" placeholder="Password" />
+ </div>
+ <button type="submit" className="btn btn-default">Login</button>
+ </form>
+ );
+ }
+});
+
+function getStateFromStores() {
+ var members = ChannelStore.getAllMembers();
+ var team_member_map = UserStore.getActiveOnlyProfiles();
+ var current_id = ChannelStore.getCurrentId();
+
+ var teammates = [];
+ for (var id in team_member_map) {
+ if (id === UserStore.getCurrentId()) continue;
+ teammates.push(team_member_map[id]);
+ }
+
+ // Create lists of all read and unread direct channels
+ var showDirectChannels = [];
+ var readDirectChannels = [];
+ for (var i = 0; i < teammates.length; i++) {
+ var teammate = teammates[i];
+
+ if (teammate.id == UserStore.getCurrentId()) {
+ continue;
+ }
+
+ var channelName = "";
+ if (teammate.id > UserStore.getCurrentId()) {
+ channelName = UserStore.getCurrentId() + '__' + teammate.id;
+ } else {
+ channelName = teammate.id + '__' + UserStore.getCurrentId();
+ }
+
+ var channel = ChannelStore.getByName(channelName);
+
+ if (channel != null) {
+ channel.display_name = teammate.full_name.trim() != "" ? teammate.full_name : teammate.username;
+ channel.teammate_username = teammate.username;
+
+ channel.status = UserStore.getStatus(teammate.id);
+
+ var channelMember = members[channel.id];
+ var msg_count = channel.total_msg_count - channelMember.msg_count;
+ if (msg_count > 0) {
+ channel.unread = msg_count;
+ showDirectChannels.push(channel);
+ } else if (current_id === channel.id) {
+ showDirectChannels.push(channel);
+ } else {
+ readDirectChannels.push(channel);
+ }
+ } else {
+ var tempChannel = {};
+ tempChannel.fake = true;
+ tempChannel.name = channelName;
+ tempChannel.display_name = teammate.full_name.trim() != "" ? teammate.full_name : teammate.username;
+ tempChannel.status = UserStore.getStatus(teammate.id);
+ tempChannel.last_post_at = 0;
+ readDirectChannels.push(tempChannel);
+ }
+ }
+
+ // If we don't have MAX_DMS unread channels, sort the read list by last_post_at
+ if (showDirectChannels.length < Constants.MAX_DMS) {
+ readDirectChannels.sort(function(a,b) {
+ // sort by last_post_at first
+ if (a.last_post_at > b.last_post_at) return -1;
+ if (a.last_post_at < b.last_post_at) return 1;
+ // if last_post_at is equal, sort by name
+ if (a.display_name < b.display_name) return -1;
+ if (a.display_name > b.display_name) return 1;
+ return 0;
+ });
+
+ var index = 0;
+ while (showDirectChannels.length < Constants.MAX_DMS && index < readDirectChannels.length) {
+ showDirectChannels.push(readDirectChannels[index]);
+ index++;
+ }
+ readDirectChannels = readDirectChannels.slice(index);
+
+ showDirectChannels.sort(function(a,b) {
+ if (a.display_name < b.display_name) return -1;
+ if (a.display_name > b.display_name) return 1;
+ return 0;
+ });
+ }
+
+ return {
+ active_id: current_id,
+ channels: ChannelStore.getAll(),
+ members: members,
+ showDirectChannels: showDirectChannels,
+ hideDirectChannels: readDirectChannels
+ };
+}
+
+var SidebarLoggedIn = React.createClass({
+ componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+ UserStore.addChangeListener(this._onChange);
+ UserStore.addStatusesChangeListener(this._onChange);
+ SocketStore.addChangeListener(this._onSocketChange);
+ $(".nav-pills__container").perfectScrollbar();
+
+ this.updateTitle();
+ },
+ componentDidUpdate: function() {
+ this.updateTitle();
+ },
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ UserStore.removeChangeListener(this._onChange);
+ UserStore.removeStatusesChangeListener(this._onChange);
+ SocketStore.removeChangeListener(this._onSocketChange);
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ },
+ _onSocketChange: function(msg) {
+ if (msg.action == "posted") {
+ if (ChannelStore.getCurrentId() === msg.channel_id) {
+ AsyncClient.getChannels(true, window.isActive);
+ } else {
+ AsyncClient.getChannels(true);
+ }
+
+ if (UserStore.getCurrentId() != msg.user_id) {
+
+ var mentions = msg.props.mentions ? JSON.parse(msg.props.mentions) : [];
+ var channel = ChannelStore.get(msg.channel_id);
+
+ var user = UserStore.getCurrentUser();
+ if (user.notify_props && ((user.notify_props.desktop === "mention" && mentions.indexOf(user.id) === -1 && channel.type !== 'D') || user.notify_props.desktop === "none")) {
+ return;
+ }
+
+ var member = ChannelStore.getMember(msg.channel_id);
+ if ((member.notify_level === "mention" && mentions.indexOf(user.id) === -1) || member.notify_level === "none" || member.notify_level === "quiet") {
+ return;
+ }
+
+ var username = "Someone";
+ if (UserStore.hasProfile(msg.user_id)) {
+ username = UserStore.getProfile(msg.user_id).username;
+ }
+
+ var title = channel ? channel.display_name : "Posted";
+
+ var repRegex = new RegExp("<br>", "g");
+ var post = JSON.parse(msg.props.post);
+ var msg = post.message.replace(repRegex, "\n").split("\n")[0].replace("<mention>", "").replace("</mention>", "");
+ if (msg.length > 50) {
+ msg = msg.substring(0,49) + "...";
+ }
+ utils.notifyMe(title, username + " wrote: " + msg, channel);
+ if (!user.notify_props || user.notify_props.desktop_sound === "true") {
+ utils.ding();
+ }
+ }
+
+ } else if (msg.action == "viewed") {
+ if (ChannelStore.getCurrentId() != msg.channel_id) {
+ AsyncClient.getChannels(true);
+ }
+ }
+ },
+ updateTitle: function() {
+ var channel = ChannelStore.getCurrent();
+ if (channel) {
+ if (channel.type === 'D') {
+ userIds = channel.name.split('__');
+ if (userIds.length < 2) return;
+ if (userIds[0] == UserStore.getCurrentId() && UserStore.getProfile(userIds[1])) {
+ document.title = UserStore.getProfile(userIds[1]).username + " " + document.title.substring(document.title.lastIndexOf("-"));
+ } else if (userIds[1] == UserStore.getCurrentId() && UserStore.getProfile(userIds[0])) {
+ document.title = UserStore.getProfile(userIds[0]).username + " " + document.title.substring(document.title.lastIndexOf("-"));
+ }
+ } else {
+ document.title = channel.display_name + " " + document.title.substring(document.title.lastIndexOf("-"))
+ }
+ }
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var members = this.state.members;
+ var newsActive = window.location.pathname === "/" ? "active" : "";
+ var badgesActive = false;
+ var self = this;
+ var channelItems = this.state.channels.map(function(channel) {
+ if (channel.type != 'O') {
+ return "";
+ }
+
+ var channelMember = members[channel.id];
+ var active = channel.id === self.state.active_id ? "active" : "";
+
+ var msg_count = channel.total_msg_count - channelMember.msg_count;
+ var titleClass = ""
+ if (msg_count > 0 && channelMember.notify_level !== "quiet") {
+ titleClass = "unread-title"
+ }
+
+ var badge = "";
+ if (channelMember.mention_count > 0) {
+ badge = <span className="badge pull-right small">{channelMember.mention_count}</span>;
+ badgesActive = true;
+ titleClass = "unread-title"
+ }
+
+ return (
+ <li key={channel.id} className={active}><a className={"sidebar-channel " + titleClass} href="#" onClick={function(e){e.preventDefault(); utils.switchChannel(channel);}}>{badge}{channel.display_name}</a></li>
+ );
+ });
+
+ var privateChannelItems = this.state.channels.map(function(channel) {
+ if (channel.type != 'P') {
+ return "";
+ }
+
+ var channelMember = members[channel.id];
+ var active = channel.id === self.state.active_id ? "active" : "";
+
+ var msg_count = channel.total_msg_count - channelMember.msg_count;
+ var titleClass = ""
+ if (msg_count > 0 && channelMember.notify_level !== "quiet") {
+ titleClass = "unread-title"
+ }
+
+ var badge = "";
+ if (channelMember.mention_count > 0) {
+ badge = <span className="badge pull-right small">{channelMember.mention_count}</span>;
+ badgesActive = true;
+ titleClass = "unread-title"
+ }
+
+ return (
+ <li key={channel.id} className={active}><a className={"sidebar-channel " + titleClass} href="#" onClick={function(e){e.preventDefault(); utils.switchChannel(channel);}}>{badge}{channel.display_name}</a></li>
+ );
+ });
+
+ var directMessageItems = this.state.showDirectChannels.map(function(channel) {
+ var badge = "";
+ var titleClass = "";
+
+ var statusIcon = "";
+ if (channel.status === "online") {
+ statusIcon = Constants.ONLINE_ICON_SVG;
+ } else if (channel.status === "away") {
+ statusIcon = Constants.ONLINE_ICON_SVG;
+ } else {
+ statusIcon = Constants.OFFLINE_ICON_SVG;
+ }
+
+ if (!channel.fake) {
+ var active = channel.id === self.state.active_id ? "active" : "";
+
+ if (channel.unread) {
+ badge = <span className="badge pull-right small">{channel.unread}</span>;
+ badgesActive = true;
+ titleClass = "unread-title"
+ }
+
+ return (
+ <li key={channel.name} className={active}><a className={"sidebar-channel " + titleClass} href="#" onClick={function(e){e.preventDefault(); utils.switchChannel(channel, channel.teammate_username);}}><span className="status" dangerouslySetInnerHTML={{__html: statusIcon}} /> {badge}{channel.display_name}</a></li>
+ );
+ } else {
+ return (
+ <li key={channel.name} className={active}><a className={"sidebar-channel " + titleClass} href={"/channels/"+channel.name}><span className="status" dangerouslySetInnerHTML={{__html: statusIcon}} /> {badge}{channel.display_name}</a></li>
+ );
+ }
+
+ });
+
+ var link = document.createElement('link');
+ link.type = 'image/x-icon';
+ link.rel = 'shortcut icon';
+ link.id = 'favicon';
+ if (badgesActive) {
+ link.href = '/static/images/redfavicon.ico';
+ } else {
+ link.href = '/static/images/favicon.ico';
+ }
+ var head = document.getElementsByTagName('head')[0];
+ var oldLink = document.getElementById('favicon');
+ if (oldLink) {
+ head.removeChild(oldLink);
+ }
+ head.appendChild(link);
+
+ if (channelItems.length == 0) {
+ <li><small>Loading...</small></li>
+ }
+
+ if (privateChannelItems.length == 0) {
+ <li><small>Loading...</small></li>
+ }
+ return (
+ <div>
+ <SidebarHeader teamName={this.props.teamName} teamType={this.props.teamType} />
+ <SearchBox />
+
+ <div className="nav-pills__container">
+ <ul className="nav nav-pills nav-stacked">
+ <li><h4>Channels<a className="add-channel-btn" href="#" data-toggle="modal" data-target="#new_channel" data-channeltype="O">+</a></h4></li>
+ {channelItems}
+ <li><a href="#" data-toggle="modal" className="nav-more" data-target="#more_channels" data-channeltype="O">More...</a></li>
+ </ul>
+
+ <ul className="nav nav-pills nav-stacked">
+ <li><h4>Private Groups<a className="add-channel-btn" href="#" data-toggle="modal" data-target="#new_channel" data-channeltype="P">+</a></h4></li>
+ {privateChannelItems}
+ </ul>
+ <ul className="nav nav-pills nav-stacked">
+ <li><h4>Direct Messages</h4></li>
+ {directMessageItems}
+ { this.state.hideDirectChannels.length > 0 ?
+ <li><a href="#" data-toggle="modal" className="nav-more" data-target="#more_direct_channels" data-channels={JSON.stringify(this.state.hideDirectChannels)}>{"More ("+this.state.hideDirectChannels.length+")"}</a></li>
+ : "" }
+ </ul>
+ </div>
+ </div>
+ );
+ }
+});
+
+var SidebarLoggedOut = React.createClass({
+ render: function() {
+ return (
+ <div>
+ <SidebarHeader teamName={this.props.teamName} />
+ <SidebarLoginForm />
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ render: function() {
+ var currentId = UserStore.getCurrentId();
+ if (currentId != null) {
+ return <SidebarLoggedIn teamName={this.props.teamName} teamType={this.props.teamType} />;
+ } else {
+ return <SidebarLoggedOut teamName={this.props.teamName} />;
+ }
+ }
+});
diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx
new file mode 100644
index 000000000..5a872b7a0
--- /dev/null
+++ b/web/react/components/sidebar_header.jsx
@@ -0,0 +1,134 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+
+function getStateFromStores() {
+ return { teams: UserStore.getTeams() };
+}
+
+var NavbarDropdown = React.createClass({
+ handleLogoutClick: function(e) {
+ e.preventDefault();
+ client.logout();
+ },
+ componentDidMount: function() {
+ UserStore.addTeamsChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ UserStore.removeTeamsChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ if (this.isMounted()) {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var team_link = "";
+ var invite_link = "";
+ var manage_link = "";
+ var rename_link = "";
+ var currentUser = UserStore.getCurrentUser()
+ var isAdmin = false;
+
+ if (currentUser != null) {
+ isAdmin = currentUser.roles.indexOf("admin") > -1;
+
+ invite_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#invite_member">Invite New Member</a>
+ </li>
+ );
+
+ if (this.props.teamType == "O") {
+ team_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#get_link" data-title="Team Invite" data-value={location.origin+"/signup_user_complete/?id="+currentUser.team_id}>Get Team Invite Link</a>
+ </li>
+ );
+ }
+ }
+
+ if (isAdmin) {
+ manage_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#team_members">Manage Team</a>
+ </li>
+ );
+ rename_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#rename_team_link">Rename</a>
+ </li>
+ );
+ }
+
+ var teams = [];
+
+ if (this.state.teams.length > 1) {
+ for (var i = 0; i < this.state.teams.length; i++) {
+ var domain = this.state.teams[i];
+
+ if (domain == utils.getSubDomain())
+ continue;
+
+ if (teams.length == 0)
+ teams.push(<li className="divider" key="div"></li>);
+
+ teams.push(<li key={ domain }><a href={window.location.protocol + "//" + domain + "." + utils.getDomainWithOutSub() }>Switch to { domain }</a></li>);
+ }
+ }
+
+ return (
+ <ul className="nav navbar-nav navbar-right">
+ <li className="dropdown">
+ <a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
+ <i className="dropdown__icon"></i>
+ </a>
+ <ul className="dropdown-menu" role="menu">
+ <li><a href="#" data-toggle="modal" data-target="#settings_modal">Account Settings</a></li>
+ { invite_link }
+ { team_link }
+ { manage_link }
+ { rename_link }
+ <li><a href="#" onClick={this.handleLogoutClick}>Logout</a></li>
+ { teams }
+ <li className="divider"></li>
+ <li><a target="_blank" href={config.HelpLink}>Help</a></li>
+ <li><a target="_blank" href={config.ReportProblemLink}>Report a Problem</a></li>
+ </ul>
+ </li>
+ </ul>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+ var teamName = this.props.teamName ? this.props.teamName : config.SiteName;
+
+ return (
+ <div className="team__header theme">
+ <a className="team__name" href="/channels/town-square">{ teamName }</a>
+ <NavbarDropdown teamType={this.props.teamType} />
+ </div>
+ );
+ }
+});
+
+
+
diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx
new file mode 100644
index 000000000..8334b345b
--- /dev/null
+++ b/web/react/components/sidebar_right.jsx
@@ -0,0 +1,84 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var SearchResults =require('./search_results.jsx');
+var PostRight =require('./post_right.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var Constants = require('../utils/constants.jsx');
+var utils = require('../utils/utils.jsx');
+
+function getStateFromStores(from_search) {
+ return { search_visible: PostStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: PostStore.getIsMentionSearch() };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ PostStore.addSearchChangeListener(this._onSearchChange);
+ PostStore.addSelectedPostChangeListener(this._onSelectedChange);
+ },
+ componentWillUnmount: function() {
+ PostStore.removeSearchChangeListener(this._onSearchChange);
+ PostStore.removeSelectedPostChangeListener(this._onSelectedChange);
+ },
+ _onSelectedChange: function(from_search) {
+ if (this.isMounted()) {
+ var newState = getStateFromStores(from_search);
+ newState.from_search = from_search;
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ _onSearchChange: function() {
+ if (this.isMounted()) {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ resize: function() {
+ $(".post-list-holder-by-time").scrollTop(100000);
+ $(".post-list-holder-by-time").perfectScrollbar('update');
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ if (! (this.state.search_visible || this.state.post_right_visible)) {
+ $('.inner__wrap').removeClass('move--left').removeClass('move--right');
+ $('.sidebar--right').removeClass('move--left');
+ this.resize();
+ return (
+ <div></div>
+ );
+ }
+
+ $('.inner__wrap').removeClass('.move--right').addClass('move--left');
+ $('.sidebar--left').removeClass('move--right');
+ $('.sidebar--right').addClass('move--left');
+ $('.sidebar--right').prepend('<div class="sidebar__overlay"></div>');
+ this.resize();
+ setTimeout(function(){
+ $('.sidebar__overlay').fadeOut("200", function(){
+ $(this).remove();
+ });
+ },500)
+
+ var content = "";
+
+ if (this.state.search_visible) {
+ content = <SearchResults isMentionSearch={this.state.is_mention_search} />;
+ }
+ else if (this.state.post_right_visible) {
+ content = <PostRight fromSearch={this.state.from_search} isMentionSearch={this.state.is_mention_search} />;
+ }
+
+ return (
+ <div className="sidebar-right-container">
+ { content }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx
new file mode 100644
index 000000000..d0c139d1a
--- /dev/null
+++ b/web/react/components/sidebar_right_menu.jsx
@@ -0,0 +1,76 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var client = require('../utils/client.jsx');
+
+module.exports = React.createClass({
+ handleLogoutClick: function(e) {
+ e.preventDefault();
+ client.logout();
+ },
+ render: function() {
+ var team_link = "";
+ var invite_link = "";
+ var manage_link = "";
+ var rename_link = "";
+ var currentUser = UserStore.getCurrentUser()
+ var isAdmin = false;
+
+ if (currentUser != null) {
+ isAdmin = currentUser.roles.indexOf("admin") > -1;
+
+ invite_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#invite_member"><i className="glyphicon glyphicon-user"></i>Invite New Member</a>
+ </li>
+ );
+
+ if (this.props.teamType == "O") {
+ team_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#get_link" data-title="Team Invite" data-value={location.origin+"/signup_user_complete/?id="+currentUser.team_id}><i className="glyphicon glyphicon-link"></i>Get Team Invite Link</a>
+ </li>
+ );
+ }
+ }
+
+ if (isAdmin) {
+ manage_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#team_members"><i className="glyphicon glyphicon-wrench"></i>Manage Team</a>
+ </li>
+ );
+ rename_link = (
+ <li>
+ <a href="#" data-toggle="modal" data-target="#rename_team_link"><i className="glyphicon glyphicon-pencil"></i>Rename</a>
+ </li>
+ );
+ }
+
+ var siteName = config.SiteName != null ? config.SiteName : "";
+ var teamName = this.props.teamName ? this.props.teamName : siteName;
+
+ return (
+ <div>
+ <div className="team__header theme">
+ <a className="team__name" href="/channels/town-square">{ teamName }</a>
+ </div>
+
+ <div className="nav-pills__container">
+ <ul className="nav nav-pills nav-stacked">
+ <li><a href="#" data-toggle="modal" data-target="#settings_modal"><i className="glyphicon glyphicon-cog"></i>Account Settings</a></li>
+ { invite_link }
+ { team_link }
+ { manage_link }
+ { rename_link }
+ <li><a href="#" onClick={this.handleLogoutClick}><i className="glyphicon glyphicon-log-out"></i>Logout</a></li>
+ <li className="divider"></li>
+ <li><a target="_blank" href="/static/help/configure_links.html"><i className="glyphicon glyphicon-question-sign"></i>Help</a></li>
+ <li><a target="_blank" href="/static/help/configure_links.html"><i className="glyphicon glyphicon-earphone"></i>Report a Problem</a></li>
+ </ul>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx
new file mode 100644
index 000000000..22086250c
--- /dev/null
+++ b/web/react/components/signup_team.jsx
@@ -0,0 +1,78 @@
+// 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');
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+ var team = {};
+ var state = { server_error: "" };
+
+ team.email = this.refs.email.getDOMNode().value.trim().toLowerCase();
+ if (!team.email || !utils.isEmail(team.email)) {
+ state.email_error = "Please enter a valid email address";
+ state.inValid = true;
+ }
+ else {
+ state.email_error = "";
+ }
+
+ team.name = this.refs.name.getDOMNode().value.trim();
+ if (!team.name) {
+ state.name_error = "This field is required";
+ state.inValid = true;
+ }
+ else {
+ state.name_error = "";
+ }
+
+ if (state.inValid) {
+ this.setState(state);
+ return;
+ }
+
+ client.signupTeam(team.email, team.name,
+ function(data) {
+ if (data["follow_link"]) {
+ window.location.href = data["follow_link"];
+ }
+ else {
+ window.location.href = "/signup_team_confirm/?email=" + encodeURIComponent(team.email);
+ }
+ }.bind(this),
+ function(err) {
+ state.server_error = err.message;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ var email_error = this.state.email_error ? <label className='control-label'>{ this.state.email_error }</label> : null;
+ var name_error = this.state.name_error ? <label className='control-label'>{ this.state.name_error }</label> : null;
+ var server_error = this.state.server_error ? <div className={ "form-group has-error" }><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ return (
+ <form role="form" onSubmit={this.handleSubmit}>
+ <div className={ email_error ? "form-group has-error" : "form-group" }>
+ <input type="email" ref="email" className="form-control" placeholder="Email Address" maxLength="128" />
+ { email_error }
+ </div>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <input type="text" ref="name" className="form-control" placeholder={utils.toTitleCase(strings.Company) + " Name"} maxLength="64" />
+ { name_error }
+ </div>
+ { server_error }
+ <button className="btn btn-md btn-primary" type="submit">Sign up for Free</button>
+ </form>
+ );
+ }
+});
+
+
diff --git a/web/react/components/signup_team_complete.jsx b/web/react/components/signup_team_complete.jsx
new file mode 100644
index 000000000..066161a10
--- /dev/null
+++ b/web/react/components/signup_team_complete.jsx
@@ -0,0 +1,644 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+var constants = require('../utils/constants.jsx')
+
+WelcomePage = React.createClass({
+ submitNext: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "team_name";
+ this.props.updateParent(this.props.state);
+ },
+ handleDiffEmail: function (e) {
+ e.preventDefault();
+ this.setState({ use_diff: true });
+ },
+ handleDiffSubmit: function (e) {
+ e.preventDefault();
+
+ var state = { use_diff: true, server_error: "" };
+
+ var email = this.refs.email.getDOMNode().value.trim().toLowerCase();
+ if (!email || !utils.isEmail(email)) {
+ state.email_error = "Please enter a valid email address";
+ this.setState(state);
+ }
+ else {
+ state.email_error = "";
+ }
+
+ client.signupTeam(email, this.props.state.team.name,
+ function(data) {
+ this.props.state.wizard = "finished";
+ this.props.updateParent(this.props.state);
+ window.location.href = "/signup_team_confirm/?email=" + encodeURI(email);
+ }.bind(this),
+ function(err) {
+ this.state.server_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { use_diff: false };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_01_welcome');
+
+ var email_error = this.state.email_error ? <label className="control-label">{ this.state.email_error }</label> : null;
+ var server_error = this.state.server_error ? <div className={ "form-group has-error" }><label className="control-label">{ this.state.server_error }</label></div> : null;
+
+ return (
+ <div>
+ <p>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>Welcome!</h2>
+ <h3>{"Let's set up your " + strings.Team + " on " + config.SiteName + "."}</h3>
+ </p>
+ <p>
+ Please confirm your email address:<br />
+ <span className="black">{ this.props.state.team.email }</span><br />
+ </p>
+ <div className="form-group">
+ <button className="btn-primary btn form-group" onClick={this.submitNext}><i className="glyphicon glyphicon-ok"></i>Yes, this address is correct</button>
+ </div>
+ <hr />
+ <p>If this is not correct, you can switch to a different email. We'll send you a new invite right away.</p>
+ <div className={ this.state.use_diff ? "" : "hidden" }>
+ <div className={ email_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <input type="email" ref="email" className="form-control" placeholder="Email Address" maxLength="128" />
+ </div>
+ </div>
+ { email_error }
+ </div>
+ { server_error }
+ <button className="btn btn-md btn-primary" onClick={this.handleDiffSubmit} type="submit">Use this instead</button>
+ </div>
+ <button onClick={this.handleDiffEmail} className={ this.state.use_diff ? "btn-default btn hidden" : "btn-default btn" }>Use a different address</button>
+ </div>
+ );
+ }
+});
+
+TeamNamePage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "welcome";
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ var name = this.refs.name.getDOMNode().value.trim();
+ if (!name) {
+ this.setState({name_error: "This field is required"});
+ return;
+ }
+
+ this.props.state.wizard = "team_url";
+ this.props.state.team.name = name;
+ this.props.updateParent(this.props.state);
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_02_name');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+
+ <h2>{utils.toTitleCase(strings.Team) + " Name"}</h2>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <input type="text" ref="name" className="form-control" placeholder="" maxLength="128" defaultValue={this.props.state.team.name} />
+ </div>
+ </div>
+ { name_error }
+ </div>
+ <p>{"Your " + strings.Team + " name shows in menus and headings. It may include the name of your " + strings.Company + ", but it's not required."}</p>
+ <button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;
+ <button className="btn-primary btn" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button>
+ </div>
+ );
+ }
+});
+
+TeamUrlPage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "team_name";
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ var name = this.refs.name.getDOMNode().value.trim();
+ if (!name) {
+ this.setState({name_error: "This field is required"});
+ return;
+ }
+
+ var cleaned_name = utils.cleanUpUrlable(name);
+ if (cleaned_name != name) {
+ this.setState({name_error: "Must be lowercase alphanumeric characters"});
+ return;
+ }
+ else if (cleaned_name.length <= 3 || cleaned_name.length > 15) {
+ this.setState({name_error: "Domain must be 4 or more characters up to a maximum of 15"})
+ return;
+ }
+
+ for (var index = 0; index < constants.RESERVED_DOMAINS.length; index++) {
+ if (cleaned_name.indexOf(constants.RESERVED_DOMAINS[index]) == 0) {
+ this.setState({name_error: "This Team URL name is unavailable"})
+ return;
+ }
+ }
+
+ client.findTeamByDomain(name,
+ function(data) {
+ if (!data) {
+ if (config.AllowSignupDomainsWizard) {
+ this.props.state.wizard = "allowed_domains";
+ } else {
+ this.props.state.wizard = "send_invites";
+ this.props.state.team.type = 'O';
+ }
+
+ this.props.state.team.domain = name;
+ this.props.updateParent(this.props.state);
+ }
+ else {
+ this.state.name_error = "This URL is unavailable. Please try another.";
+ this.setState(this.state);
+ }
+ }.bind(this),
+ function(err) {
+ this.state.name_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_03_url');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>{utils.toTitleCase(strings.Team) + " URL"}</h2>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <div className="input-group">
+ <input type="text" ref="name" className="form-control text-right" placeholder="" maxLength="128" defaultValue={this.props.state.team.domain} />
+ <span className="input-group-addon">.{ utils.getDomainWithOutSub() }</span>
+ </div>
+ </div>
+ </div>
+ { name_error }
+ </div>
+ <p className="black">{"Pick something short and memorable for your " + strings.Team + "'s web address."}</p>
+ <p>{"Your " + strings.Team + " URL can only contain lowercase letters, numbers and dashes. Also, it needs to start with a letter and cannot end in a dash."}</p>
+ <button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;
+ <button className="btn-primary btn" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button>
+ </div>
+ );
+ }
+});
+
+AllowedDomainsPage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "team_url";
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ if (this.refs.open_network.getDOMNode().checked) {
+ this.props.state.wizard = "send_invites";
+ this.props.state.team.type = 'O';
+ this.props.updateParent(this.props.state);
+ return;
+ }
+
+ if (this.refs.allow.getDOMNode().checked) {
+ var name = this.refs.name.getDOMNode().value.trim();
+ var domainRegex = /^\w+\.\w+$/
+ if (!name) {
+ this.setState({name_error: "This field is required"});
+ return;
+ }
+
+ if(!name.trim().match(domainRegex)) {
+ this.setState({name_error: "The domain doesn't appear valid"});
+ return;
+ }
+
+ this.props.state.wizard = "send_invites";
+ this.props.state.team.allowed_domains = name;
+ this.props.state.team.type = 'I';
+ this.props.updateParent(this.props.state);
+ }
+ else {
+ this.props.state.wizard = "send_invites";
+ this.props.state.team.type = 'I';
+ this.props.updateParent(this.props.state);
+ }
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_04_allow_domains');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>Email Domain</h2>
+ <p>
+ <div className="checkbox"><label><input type="checkbox" ref="allow" defaultChecked />{" Allow sign up and " + strings.Team + " discovery with a " + strings.Company + " email address."}</label></div>
+ </p>
+ <p>{"Check this box to allow your " + strings.Team + " members to sign up using their " + strings.Company + " email addresses if you share the same domain--otherwise, you need to invite everyone yourself."}</p>
+ <h4>{"Your " + strings.Team + "'s domain for emails"}</h4>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <div className="input-group">
+ <span className="input-group-addon">@</span>
+ <input type="text" ref="name" className="form-control" placeholder="" maxLength="128" defaultValue={this.props.state.team.allowed_domains} />
+ </div>
+ </div>
+ </div>
+ { name_error }
+ </div>
+ <p>To allow signups from multiple domains, separate each with a comma.</p>
+ <p>
+ <div className="checkbox"><label><input type="checkbox" ref="open_network" defaultChecked={this.props.state.team.type == 'O'} /> Allow anyone to signup to this domain without an invitation.</label></div>
+ </p>
+ <button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;
+ <button className="btn-primary btn" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button>
+ </div>
+ );
+ }
+});
+
+EmailItem = React.createClass({
+ getInitialState: function() {
+ return { };
+ },
+ getValue: function() {
+ return this.refs.email.getDOMNode().value.trim()
+ },
+ validate: function() {
+ var email = this.refs.email.getDOMNode().value.trim().toLowerCase();
+
+ if (!email) {
+ return true;
+ }
+
+ if (!utils.isEmail(email)) {
+ this.state.email_error = "Please enter a valid email address";
+ this.setState(this.state);
+ return false;
+ }
+ else {
+ this.state.email_error = "";
+ this.setState(this.state);
+ return true;
+ }
+ },
+ render: function() {
+
+ var email_error = this.state.email_error ? <label className="control-label">{ this.state.email_error }</label> : null;
+
+ return (
+ <div className={ email_error ? "form-group has-error" : "form-group" }>
+ <input type="email" ref="email" className="form-control" placeholder="Email Address" defaultValue={this.props.email} maxLength="128" />
+ { email_error }
+ </div>
+ );
+ }
+});
+
+
+SendInivtesPage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+
+ if (config.AllowSignupDomainsWizard) {
+ this.props.state.wizard = "allowed_domains";
+ } else {
+ this.props.state.wizard = "team_url";
+ }
+
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ var valid = true;
+ var emails = [];
+
+ for (var i = 0; i < this.props.state.invites.length; i++) {
+ if (!this.refs['email_' + i].validate()) {
+ valid = false;
+ } else {
+ emails.push(this.refs['email_' + i].getValue());
+ }
+ }
+
+ if (!valid) {
+ return;
+ }
+
+ this.props.state.wizard = "username";
+ this.props.state.invites = emails;
+ this.props.updateParent(this.props.state);
+ },
+ submitAddInvite: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "send_invites";
+ if (this.props.state.invites == null || this.props.state.invites.length == 0) {
+ this.props.state.invites = [];
+ }
+ this.props.state.invites.push("");
+ this.props.updateParent(this.props.state);
+ },
+ submitSkip: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "username";
+ this.props.updateParent(this.props.state);
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_05_send_invites');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ var emails = [];
+
+ for (var i = 0; i < this.props.state.invites.length; i++) {
+ emails.push(<EmailItem key={i} ref={'email_' + i} email={this.props.state.invites[i]} />);
+ }
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>Send Invitations</h2>
+ { emails }
+ <div className="form-group"><button className="btn-default btn" onClick={this.submitAddInvite}>Add Invitation</button></div>
+ <div className="form btn-default-group"><button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;<button className="btn-primary btn" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button></div>
+ <p>{"If you'd prefer, you can send invitations after you finish setting up the "+ strings.Team + "."}</p>
+ <div><a href="#" onClick={this.submitSkip}>Skip this step</a></div>
+ </div>
+ );
+ }
+});
+
+UsernamePage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "send_invites";
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ var name = this.refs.name.getDOMNode().value.trim();
+
+ var username_error = utils.isValidUsername(name);
+ if (username_error === "Cannot use a reserved word as a username.") {
+ this.setState({name_error: "This username is reserved, please choose a new one." });
+ return;
+ } else if (username_error) {
+ this.setState({name_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." });
+ return;
+ }
+
+
+ this.props.state.wizard = "password";
+ this.props.state.user.username = name;
+ this.props.updateParent(this.props.state);
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_06_username');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>Choose a username</h2>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <input type="text" ref="name" className="form-control" placeholder="" defaultValue={this.props.state.user.username} maxLength="128" />
+ </div>
+ </div>
+ { name_error }
+ </div>
+ <p>{"Pick something " + strings.Team + "mates will recognize. Your username is how you will appear to others"}</p>
+ <button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;
+ <button className="btn-primary btn" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button>
+ </div>
+ );
+ }
+});
+
+PasswordPage = React.createClass({
+ submitBack: function (e) {
+ e.preventDefault();
+ this.props.state.wizard = "username";
+ this.props.updateParent(this.props.state);
+ },
+ submitNext: function (e) {
+ e.preventDefault();
+
+ var password = this.refs.password.getDOMNode().value.trim();
+ if (!password || password.length < 5) {
+ this.setState({name_error: "Please enter at least 5 characters"});
+ return;
+ }
+
+ $('#finish-button').button('loading');
+ var teamSignup = JSON.parse(JSON.stringify(this.props.state));
+ teamSignup.user.password = password;
+ teamSignup.user.allow_marketing = this.refs.email_service.getDOMNode().checked;
+ delete teamSignup.wizard;
+ var ctl = this;
+
+ client.createTeamFromSignup(teamSignup,
+ function(data) {
+
+ client.track('signup', 'signup_team_08_complete');
+
+ var props = this.props;
+
+ setTimeout(function() {
+ $('#sign-up-button').button('reset');
+ props.state.wizard = "finished";
+ props.updateParent(props.state, true);
+
+ if (utils.isTestDomain()) {
+ UserStore.setLastDomain(teamSignup.team.domain);
+ UserStore.setLastEmail(teamSignup.team.email);
+ window.location.href = window.location.protocol + '//' + utils.getDomainWithOutSub() + '/login?email=' + encodeURIComponent(teamSignup.team.email);
+ }
+ else {
+ window.location.href = window.location.protocol + '//' + teamSignup.team.domain + '.' + utils.getDomainWithOutSub() + '/login?email=' + encodeURIComponent(teamSignup.team.email);
+ }
+
+ // client.loginByEmail(teamSignup.team.domain, teamSignup.team.email, teamSignup.user.password,
+ // function(data) {
+ // UserStore.setLastDomain(teamSignup.team.domain);
+ // UserStore.setLastEmail(teamSignup.team.email);
+ // UserStore.setCurrentUser(data);
+ // window.location.href = '/channels/town-square';
+ // }.bind(ctl),
+ // function(err) {
+ // this.setState({name_error: err.message});
+ // }.bind(ctl)
+ // );
+ }, 5000);
+ }.bind(this),
+ function(err) {
+ this.setState({name_error: err.message});
+ $('#sign-up-button').button('reset');
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ return { };
+ },
+ render: function() {
+
+ client.track('signup', 'signup_team_07_password');
+
+ var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null;
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h2>Choose a password</h2>
+ <p>You'll use your email address ({this.props.state.team.email}) and password to log into {config.SiteName}.</p>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <div className="row">
+ <div className="col-sm-9">
+ <input type="password" ref="password" className="form-control" placeholder="" maxLength="128" />
+ </div>
+ </div>
+ { name_error }
+ </div>
+ <div className="form-group checkbox">
+ <label><input type="checkbox" ref="email_service" /> It's ok to send me occassional email with updates about the {config.SiteName} service.</label>
+ </div>
+ <div className="form-group">
+ <button className="btn btn-default" onClick={this.submitBack}><i className="glyphicon glyphicon-chevron-left"></i> Back</button>&nbsp;
+ <button className="btn-primary btn" id="finish-button" data-loading-text={"<span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> Creating "+strings.Team+"..."} onClick={this.submitNext}>Finish</button>
+ </div>
+ <p>By proceeding to create your account and use { config.SiteName }, you agree to our <a href={ config.TermsLink }>Terms of Service</a> and <a href={ config.PrivacyLink }>Privacy Policy</a>. If you do not agree, you cannot use {config.SiteName}.</p>
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ updateParent: function(state, skipSet) {
+ localStorage.setItem(this.props.hash, JSON.stringify(state));
+
+ if (!skipSet) {
+ this.setState(state);
+ }
+ },
+ getInitialState: function() {
+ var props = null;
+ try {
+ props = JSON.parse(localStorage.getItem(this.props.hash));
+ }
+ catch(parse_error) {
+ }
+
+ if (!props) {
+ props = {};
+ props.wizard = "welcome";
+ props.team = {};
+ props.team.email = this.props.email;
+ props.team.name = this.props.name;
+ props.team.company_name = this.props.name;
+ props.team.domain = utils.cleanUpUrlable(this.props.name);
+ props.team.allowed_domains = "";
+ props.invites = [];
+ props.invites.push("");
+ props.invites.push("");
+ props.invites.push("");
+ props.user = {};
+ props.hash = this.props.hash;
+ props.data = this.props.data;
+ }
+
+ return props ;
+ },
+ render: function() {
+ if (this.state.wizard == "welcome") {
+ return <WelcomePage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "team_name") {
+ return <TeamNamePage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "team_url") {
+ return <TeamUrlPage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "allowed_domains") {
+ return <AllowedDomainsPage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "send_invites") {
+ return <SendInivtesPage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "username") {
+ return <UsernamePage state={this.state} updateParent={this.updateParent} />
+ }
+
+ if (this.state.wizard == "password") {
+ return <PasswordPage state={this.state} updateParent={this.updateParent} />
+ }
+
+ return (<div>You've already completed the signup process for this invitation or this invitation has expired.</div>);
+ }
+});
+
+
diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx
new file mode 100644
index 000000000..0fcdc92b0
--- /dev/null
+++ b/web/react/components/signup_user_complete.jsx
@@ -0,0 +1,145 @@
+// 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 UserStore = require('../stores/user_store.jsx');
+
+
+module.exports = React.createClass({
+ handleSubmit: function(e) {
+ e.preventDefault();
+
+ this.state.user.username = this.refs.name.getDOMNode().value.trim();
+ if (!this.state.user.username) {
+ this.setState({name_error: "This field is required", email_error: "", password_error: ""});
+ return;
+ }
+
+ var username_error = utils.isValidUsername(this.state.user.username)
+ if (username_error === "Cannot use a reserved word as a username.") {
+ this.setState({name_error: "This username is reserved, please choose a new one." });
+ return;
+ } else if (username_error) {
+ this.setState({name_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." });
+ return;
+ }
+
+ this.state.user.email = this.refs.email.getDOMNode().value.trim();
+ if (!this.state.user.email) {
+ this.setState({name_error: "", email_error: "This field is required", password_error: ""});
+ return;
+ }
+
+ this.state.user.password = this.refs.password.getDOMNode().value.trim();
+ if (!this.state.user.password || this.state.user.password .length < 5) {
+ this.setState({name_error: "", email_error: "", password_error: "Please enter at least 5 characters"});
+ return;
+ }
+
+ this.state.user.allow_marketing = this.refs.email_service.getDOMNode().checked;
+
+ client.createUser(this.state.user, this.state.data, this.state.hash,
+ function(data) {
+ client.track('signup', 'signup_user_02_complete');
+
+ if (data.email_verified) {
+ client.loginByEmail(this.props.domain, this.state.user.email, this.state.user.password,
+ function(data) {
+ UserStore.setLastDomain(this.props.domain);
+ UserStore.setLastEmail(this.state.user.email);
+ UserStore.setCurrentUser(data);
+ if (this.props.hash > 0)
+ localStorage.setItem(this.props.hash, JSON.stringify({wizard: "finished"}));
+ window.location.href = '/channels/town-square';
+ }.bind(this),
+ function(err) {
+ this.state.server_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ }
+ else {
+ window.location.href = "/verify?email="+ encodeURIComponent(this.state.user.email) + "&domain=" + encodeURIComponent(this.props.domain);
+ }
+ }.bind(this),
+ function(err) {
+ this.state.server_error = err.message;
+ this.setState(this.state);
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ var props = null;
+ try {
+ props = JSON.parse(localStorage.getItem(this.props.hash));
+ }
+ catch(parse_error) {
+ }
+
+ if (!props) {
+ props = {};
+ props.wizard = "welcome";
+ props.user = {};
+ props.user.team_id = this.props.team_id;
+ props.user.email = this.props.email;
+ props.hash = this.props.hash;
+ props.data = this.props.data;
+ props.original_email = this.props.email;
+ }
+
+ return props ;
+ },
+ render: function() {
+
+ client.track('signup', 'signup_user_01_welcome');
+
+ if (this.state.wizard == "finished") {
+ return (<div>You've already completed the signup process for this invitation or this invitation has expired.</div>);
+ }
+
+ var email_error = this.state.email_error ? <label className='control-label'>{ this.state.email_error }</label> : null;
+ var name_error = this.state.name_error ? <label className='control-label'>{ this.state.name_error }</label> : null;
+ var password_error = this.state.password_error ? <label className='control-label'>{ this.state.password_error }</label> : null;
+ var server_error = this.state.server_error ? <div className={ "form-group has-error" }><label className='control-label'>{ this.state.server_error }</label></div> : null;
+
+ var yourEmailIs = this.state.user.email == "" ? "" : <span>Your email address is { this.state.user.email }. </span>
+
+ var email =
+ <div className={ this.state.original_email == "" ? "" : "hidden"} >
+ <label className="control-label">Email</label>
+ <div className={ email_error ? "form-group has-error" : "form-group" }>
+ <input type="email" ref="email" className="form-control" defaultValue={ this.state.user.email } placeholder="" maxLength="128" />
+ { email_error }
+ </div>
+ </div>
+
+ return (
+ <div>
+ <img className="signup-team-logo" src="/static/images/logo.png" />
+ <h4>Welcome to { config.SiteName }</h4>
+ <p>{"Choose your username and password for the " + this.props.team_name + " " + strings.Team +"."}</p>
+ <label className="control-label">Username</label>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <input type="text" ref="name" className="form-control" placeholder="" maxLength="128" />
+ { name_error }
+ </div>
+ { email }
+ <label className="control-label">Password</label>
+ <div className={ name_error ? "form-group has-error" : "form-group" }>
+ <input type="password" ref="password" className="form-control" placeholder="" maxLength="128" />
+ { password_error }
+ </div>
+ <p>{"Pick something " + strings.Team + "mates will recognize. Your username is how you will appear to others"}</p>
+ <p className={ this.state.original_email == "" ? "hidden" : ""}>{ yourEmailIs } You’ll use this address to sign in to {config.SiteName}.</p>
+ <div className="checkbox"><label><input type="checkbox" ref="email_service" /> It's ok to send me occassional email with updates about the {config.SiteName} service. </label></div>
+ <p><button onClick={this.handleSubmit} className="btn-primary btn">Create Account</button></p>
+ { server_error }
+ <p>By proceeding to create your account and use { config.SiteName }, you agree to our <a href={ config.TermsLink }>Terms of Service</a> and <a href={ config.PrivacyLink }>Privacy Policy</a>. If you do not agree, you cannot use {config.SiteName}.</p>
+ </div>
+ );
+ }
+});
+
+
diff --git a/web/react/components/team_members.jsx b/web/react/components/team_members.jsx
new file mode 100644
index 000000000..6b978f88b
--- /dev/null
+++ b/web/react/components/team_members.jsx
@@ -0,0 +1,78 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var MemberListTeam = require('./member_list_team.jsx');
+var Client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+
+function getStateFromStores() {
+ var users = UserStore.getProfiles();
+ var member_list = [];
+ for (var id in users) member_list.push(users[id]);
+
+ member_list.sort(function(a,b) {
+ if (a.username < b.username) return -1;
+ if (a.username > b.username) return 1;
+ return 0;
+ });
+
+ return {
+ member_list: member_list
+ };
+}
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ UserStore.addChangeListener(this._onChange);
+
+ var self = this;
+ $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
+ self.setState({ render_members: false });
+ });
+
+ $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
+ self.setState({ render_members: true });
+ });
+ },
+ componentWillUnmount: function() {
+ UserStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ },
+ getInitialState: function() {
+ return getStateFromStores();
+ },
+ render: function() {
+ var server_error = this.state.server_error ? <label className='has-error control-label'>{this.state.server_error}</label> : null;
+
+ return (
+ <div className="modal fade" ref="modal" id="team_members" 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" aria-label="Close" data-reactid=".5.0.0.0.0"><span aria-hidden="true" data-reactid=".5.0.0.0.0.0">×</span></button>
+ <h4 className="modal-title" id="myModalLabel">{this.props.teamName + " Members"}</h4>
+ </div>
+ <div ref="modalBody" className="modal-body">
+ <div className="channel-settings">
+ <div className="team-member-list">
+ { this.state.render_members ? <MemberListTeam users={this.state.member_list} /> : "" }
+ </div>
+ { server_error }
+ </div>
+ </div>
+ <div className="modal-footer">
+ <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
new file mode 100644
index 000000000..45798809f
--- /dev/null
+++ b/web/react/components/textbox.jsx
@@ -0,0 +1,290 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var SocketStore = require('../stores/socket_store.jsx');
+var MsgTyping = require('./msg_typing.jsx');
+var MentionList = require('./mention_list.jsx');
+var CommandList = require('./command_list.jsx');
+
+var utils = require('../utils/utils.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+module.exports = React.createClass({
+ caret: -1,
+ addedMention: false,
+ doProcessMentions: false,
+ mentions: [],
+ componentDidMount: function() {
+ PostStore.addAddMentionListener(this._onChange);
+
+ this.resize();
+ this.processMentions();
+ this.updateTextdiv();
+ },
+ componentWillUnmount: function() {
+ PostStore.removeAddMentionListener(this._onChange);
+ },
+ _onChange: function(id, username) {
+ if (id !== this.props.id) return;
+ this.addMention(username);
+ },
+ componentDidUpdate: function() {
+ if (this.caret >= 0) {
+ utils.setCaretPosition(this.refs.message.getDOMNode(), this.caret)
+ this.caret = -1;
+ }
+ if (this.doProcessMentions) {
+ this.processMentions();
+ this.doProcessMentions = false;
+ }
+ this.updateTextdiv();
+ this.resize();
+ },
+ componentWillReceiveProps: function(nextProps) {
+ if (!this.addedMention) {
+ this.checkForNewMention(nextProps.messageText);
+ }
+ var text = this.refs.message.getDOMNode().value;
+ if (nextProps.channelId != this.props.channelId || nextProps.messageText !== text) {
+ this.doProcessMentions = true;
+ }
+ this.addedMention = false;
+ this.refs.commands.getSuggestedCommands(nextProps.messageText);
+ this.resize();
+ },
+ getInitialState: function() {
+ return { mentionText: '-1', mentions: [] };
+ },
+ updateMentionTab: function(mentionText, excludeList) {
+ var self = this;
+ // using setTimeout so dispatch isn't called during an in progress dispatch
+ setTimeout(function() {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.RECIEVED_MENTION_DATA,
+ id: self.props.id,
+ mention_text: mentionText,
+ exclude_list: excludeList
+ });
+ }, 1);
+ },
+ updateTextdiv: function() {
+ var html = utils.insertHtmlEntities(this.refs.message.getDOMNode().value);
+ for (var k in this.mentions) {
+ var m = this.mentions[k];
+ var re = new RegExp('( |^)@' + m + '( |$|\n)', 'm');
+ html = html.replace(re, '$1<span class="mention">@'+m+'</span>$2');
+ }
+ $(this.refs.textdiv.getDOMNode()).html(html);
+ },
+ handleChange: function() {
+ this.props.onUserInput(this.refs.message.getDOMNode().value);
+ this.resize();
+ },
+ handleKeyPress: function(e) {
+ var text = this.refs.message.getDOMNode().value;
+
+ if (!this.refs.commands.isEmpty() && text.indexOf("/") == 0 && e.which==13) {
+ this.refs.commands.addFirstCommand();
+ e.preventDefault();
+ return;
+ }
+
+ if ( !this.doProcessMentions) {
+ var caret = utils.getCaretPosition(this.refs.message.getDOMNode());
+ var preText = text.substring(0, caret);
+ var lastSpace = preText.lastIndexOf(' ');
+ var lastAt = preText.lastIndexOf('@');
+
+ if (caret > lastAt && lastSpace < lastAt) {
+ this.doProcessMentions = true;
+ }
+ }
+
+ this.props.onKeyPress(e);
+ },
+ handleKeyDown: function(e) {
+ if (utils.getSelectedText(this.refs.message.getDOMNode()) !== '') {
+ this.doProcessMentions = true;
+ }
+
+ if (e.keyCode === 8) {
+ this.handleBackspace(e);
+ }
+ },
+ handleBackspace: function(e) {
+ var text = this.refs.message.getDOMNode().value;
+ if (text.indexOf("/") == 0) {
+ this.refs.commands.getSuggestedCommands(text.substring(0, text.length-1));
+ }
+
+ if (this.doProcessMentions) return;
+
+ var caret = utils.getCaretPosition(this.refs.message.getDOMNode());
+ var preText = text.substring(0, caret);
+ var lastSpace = preText.lastIndexOf(' ');
+ var lastAt = preText.lastIndexOf('@');
+
+ if (caret > lastAt && (lastSpace > lastAt || lastSpace === -1)) {
+ this.doProcessMentions = true;
+ }
+ },
+ processMentions: function() {
+ /* First, find all the possible mentions, highlight them in the HTML and add
+ them all to a list of mentions */
+ var text = utils.insertHtmlEntities(this.refs.message.getDOMNode().value);
+
+ var profileMap = UserStore.getProfilesUsernameMap();
+
+ var re1 = /@([a-z0-9_]+)( |$|\n)/gi;
+
+ var matches = text.match(re1);
+
+ if (!matches) {
+ $(this.refs.textdiv.getDOMNode()).text(text);
+ this.updateMentionTab(null, []);
+ this.mentions = [];
+ return;
+ }
+
+ var mentions = [];
+ for (var i = 0; i < matches.length; i++) {
+ var m = matches[i].substring(1,matches[i].length).trim();
+ if (m in profileMap && mentions.indexOf(m) === -1) {
+ mentions.push(m);
+ }
+ }
+
+ /* Figure out what the user is currently typing. If it's a mention then we don't
+ want to highlight it and add it to the mention list yet, so we remove it if
+ there is only one occurence of that mention so far. */
+ var caret = utils.getCaretPosition(this.refs.message.getDOMNode());
+
+ var text = this.props.messageText;
+
+ var preText = text.substring(0, caret);
+
+ var atIndex = preText.lastIndexOf('@');
+ var spaceIndex = preText.lastIndexOf(' ');
+ var newLineIndex = preText.lastIndexOf('\n');
+
+ var typingMention = "";
+ if (atIndex > spaceIndex && atIndex > newLineIndex) {
+
+ typingMention = text.substring(atIndex+1, caret);
+ }
+
+ var re3 = new RegExp('@' + typingMention + '( |$|\n)', 'g');
+
+ if ((text.match(re3) || []).length === 1 && mentions.indexOf(typingMention) !== -1) {
+ mentions.splice(mentions.indexOf(typingMention), 1);
+ }
+
+ this.updateMentionTab(null, mentions);
+ this.mentions = mentions;
+ },
+ checkForNewMention: function(text) {
+ var caret = utils.getCaretPosition(this.refs.message.getDOMNode());
+
+ var preText = text.substring(0, caret);
+
+ var atIndex = preText.lastIndexOf('@');
+
+ // The @ character not typed, so nothing to do.
+ if (atIndex === -1) {
+ this.updateMentionTab('-1', null);
+ return;
+ }
+
+ var lastCharSpace = preText.lastIndexOf(String.fromCharCode(160));
+ var lastSpace = preText.lastIndexOf(' ');
+
+ // If there is a space after the last @, nothing to do.
+ if (lastSpace > atIndex || lastCharSpace > atIndex) {
+ this.setState({ mentionText: '-1' });
+ return;
+ }
+
+ // Get the name typed so far.
+ var name = preText.substring(atIndex+1, preText.length).toLowerCase();
+ this.updateMentionTab(name, null);
+ },
+ addMention: function(name) {
+ var caret = utils.getCaretPosition(this.refs.message.getDOMNode());
+
+ var text = this.props.messageText;
+
+ var preText = text.substring(0, caret);
+
+ var atIndex = preText.lastIndexOf('@');
+
+ // The @ character not typed, so nothing to do.
+ if (atIndex === -1) {
+ return;
+ }
+
+ var prefix = text.substring(0, atIndex);
+ var suffix = text.substring(caret, text.length);
+ this.caret = prefix.length + name.length + 2;
+ this.addedMention = true;
+ this.doProcessMentions = true;
+
+ this.props.onUserInput(prefix + "@" + name + " " + suffix);
+ },
+ addCommand: function(cmd) {
+ var elm = this.refs.message.getDOMNode();
+ elm.value = cmd;
+ this.handleChange();
+ },
+ scroll: function() {
+ var e = this.refs.message.getDOMNode();
+ var d = this.refs.textdiv.getDOMNode();
+ $(d).scrollTop($(e).scrollTop());
+ },
+ resize: function() {
+ var e = this.refs.message.getDOMNode();
+ var w = this.refs.wrapper.getDOMNode();
+ var d = this.refs.textdiv.getDOMNode();
+
+ var lht = parseInt($(e).css('lineHeight'),10);
+ var lines = e.scrollHeight / lht;
+ var mod = lines < 2.5 || this.props.messageText === "" ? 30 : 15;
+
+ if (e.scrollHeight - mod < 167) {
+ $(e).css({'height':'auto','overflow-y':'hidden'}).height(e.scrollHeight - mod);
+ $(d).css({'height':'auto','overflow-y':'hidden'}).height(e.scrollHeight - mod);
+ $(w).css({'height':'auto'}).height(e.scrollHeight+2);
+ } else {
+ $(e).css({'height':'auto','overflow-y':'scroll'}).height(167);
+ $(d).css({'height':'auto','overflow-y':'scroll'}).height(167);
+ $(w).css({'height':'auto'}).height(167);
+ }
+ },
+ handleFocus: function() {
+ var elm = this.refs.message.getDOMNode();
+ if (elm.title === elm.value) {
+ elm.value = "";
+ }
+ },
+ handleBlur: function() {
+ var elm = this.refs.message.getDOMNode();
+ if (elm.value === '') {
+ elm.value = elm.title;
+ }
+ },
+ handlePaste: function() {
+ this.doProcessMentions = true;
+ },
+ render: function() {
+ return (
+ <div ref="wrapper" className="textarea-wrapper">
+ <CommandList ref='commands' addCommand={this.addCommand} channelId={this.props.channelId} />
+ <div className="form-control textarea-div" ref="textdiv"/>
+ <textarea id={this.props.id} ref="message" className="form-control custom-textarea" spellCheck="true" autoComplete="off" autoCorrect="off" rows="1" placeholder={this.props.createMessage} value={this.props.messageText} onInput={this.handleChange} onChange={this.handleChange} onKeyPress={this.handleKeyPress} onKeyDown={this.handleKeyDown} onScroll={this.scroll} onFocus={this.handleFocus} onBlur={this.handleBlur} onPaste={this.handlePaste} />
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx
new file mode 100644
index 000000000..8ffad737d
--- /dev/null
+++ b/web/react/components/user_profile.jsx
@@ -0,0 +1,71 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var utils = require('../utils/utils.jsx');
+var UserStore = require('../stores/user_store.jsx');
+
+function getStateFromStores(userId) {
+ var profile = UserStore.getProfile(userId);
+
+ if (profile == null) {
+ return { profile: { id: "0", username: "..."} };
+ }
+ else {
+ return { profile: profile };
+ }
+}
+
+var id = 0;
+
+function nextId() {
+ id = id + 1;
+ return id;
+}
+
+
+module.exports = React.createClass({
+ uniqueId: null,
+ componentDidMount: function() {
+ UserStore.addChangeListener(this._onChange);
+ $("#profile_" + this.uniqueId).popover({placement : 'right', container: 'body', trigger: 'hover', html: true, delay: { "show": 200, "hide": 100 }});
+ },
+ componentWillUnmount: function() {
+ UserStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function(id) {
+ if (id == this.props.userId) {
+ var newState = getStateFromStores(this.props.userId);
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ },
+ componentWillReceiveProps: function(nextProps) {
+ if (this.props.userId != nextProps.userId) {
+ this.setState(getStateFromStores(nextProps.userId));
+ }
+ },
+ getInitialState: function() {
+ this.uniqueId = nextId();
+ return getStateFromStores(this.props.userId);
+ },
+ render: function() {
+ var name = this.props.overwriteName ? this.props.overwriteName : this.state.profile.username;
+
+
+ var data_content = ""
+ data_content += "<img style='margin: 10px' src='/api/v1/users/" + this.state.profile.id + "/image' height='128' width='128' />"
+ if (!config.ShowEmail) {
+ data_content += "<div><span style='white-space:nowrap;'>Email not shared</span></div>";
+ } else {
+ data_content += "<div><a href='mailto:'" + this.state.profile.email + "'' style='white-space:nowrap;text-transform:lowercase;'>" + this.state.profile.email + "</a></div>";
+ }
+
+ return (
+ <div style={{"cursor" : "pointer", "display" : "inline-block"}} className="user-popover" id={"profile_" + this.uniqueId} data-toggle="popover" data-content={data_content} data-original-title={this.state.profile.username} >
+ { name }
+ </div>
+ );
+ }
+});
diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx
new file mode 100644
index 000000000..b165a59ad
--- /dev/null
+++ b/web/react/components/user_settings.jsx
@@ -0,0 +1,1151 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var SettingItemMin = require('./setting_item_min.jsx');
+var SettingItemMax = require('./setting_item_max.jsx');
+var SettingPicture = require('./setting_picture.jsx');
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var utils = require('../utils/utils.jsx');
+var Constants = require('../utils/constants.jsx');
+
+function getNotificationsStateFromStores() {
+ var user = UserStore.getCurrentUser();
+ var sound = (!user.notify_props || user.notify_props.desktop_sound == undefined) ? "true" : user.notify_props.desktop_sound;
+ var desktop = (!user.notify_props || user.notify_props.desktop == undefined) ? "all" : user.notify_props.desktop;
+ var email = (!user.notify_props || user.notify_props.email == undefined) ? "true" : user.notify_props.email;
+
+ var username_key = false;
+ var mention_key = false;
+ var custom_keys = "";
+ var first_name_key = false;
+
+ if (!user.notify_props) {
+ mention_keys = user.username;
+ if (user.full_name.length > 0) mention_keys += ","+ user.full_name.split(" ")[0];
+ } else {
+ if (user.notify_props.mention_keys !== undefined) {
+ var keys = user.notify_props.mention_keys.split(',');
+
+ if (keys.indexOf(user.username) !== -1) {
+ username_key = true;
+ keys.splice(keys.indexOf(user.username), 1);
+ } else {
+ username_key = false;
+ }
+
+ if (keys.indexOf('@'+user.username) !== -1) {
+ mention_key = true;
+ keys.splice(keys.indexOf('@'+user.username), 1);
+ } else {
+ mention_key = false;
+ }
+
+ custom_keys = keys.join(',');
+ }
+
+ if (user.notify_props.first_name !== undefined) {
+ first_name_key = user.notify_props.first_name === "true";
+ }
+ }
+
+ return { notify_level: desktop, enable_email: email, enable_sound: sound, username_key: username_key, mention_key: mention_key, custom_keys: custom_keys, custom_keys_checked: custom_keys.length > 0, first_name_key: first_name_key };
+}
+
+
+var NotificationsTab = React.createClass({
+ handleSubmit: function() {
+ data = {}
+ data["user_id"] = this.props.user.id;
+ data["email"] = this.state.enable_email;
+ data["desktop_sound"] = this.state.enable_sound;
+ data["desktop"] = this.state.notify_level;
+
+ var mention_keys = [];
+ if (this.state.username_key) mention_keys.push(this.props.user.username);
+ if (this.state.mention_key) mention_keys.push('@'+this.props.user.username);
+
+ var string_keys = mention_keys.join(',');
+ if (this.state.custom_keys.length > 0 && this.state.custom_keys_checked) {
+ string_keys += ',' + this.state.custom_keys;
+ }
+
+ data["mention_keys"] = string_keys;
+ data["first_name"] = this.state.first_name_key ? "true" : "false";
+
+ client.updateUserNotifyProps(data,
+ function(data) {
+ this.props.updateSection("");
+ AsyncClient.getMe();
+ }.bind(this),
+ function(err) {
+ this.setState({ server_error: err.message });
+ }.bind(this)
+ );
+ },
+ componentDidMount: function() {
+ UserStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ UserStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var newState = getNotificationsStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ },
+ getInitialState: function() {
+ return getNotificationsStateFromStores();
+ },
+ handleNotifyRadio: function(notifyLevel) {
+ this.setState({ notify_level: notifyLevel });
+ this.refs.wrapper.getDOMNode().focus();
+ },
+ handleEmailRadio: function(enableEmail) {
+ this.setState({ enable_email: enableEmail });
+ this.refs.wrapper.getDOMNode().focus();
+ },
+ handleSoundRadio: function(enableSound) {
+ this.setState({ enable_sound: enableSound });
+ this.refs.wrapper.getDOMNode().focus();
+ },
+ updateUsernameKey: function(val) {
+ this.setState({ username_key: val });
+ },
+ updateMentionKey: function(val) {
+ this.setState({ mention_key: val });
+ },
+ updateFirstNameKey: function(val) {
+ this.setState({ first_name_key: val });
+ },
+ updateCustomMentionKeys: function() {
+ var checked = this.refs.customcheck.getDOMNode().checked;
+
+ if (checked) {
+ var text = this.refs.custommentions.getDOMNode().value;
+
+ // remove all spaces and split string into individual keys
+ this.setState({ custom_keys: text.replace(/ /g, ''), custom_keys_checked: true });
+ } else {
+ this.setState({ custom_keys: "", custom_keys_checked: false });
+ }
+ },
+ onCustomChange: function() {
+ this.refs.customcheck.getDOMNode().checked = true;
+ this.updateCustomMentionKeys();
+ },
+ render: function() {
+ var server_error = this.state.server_error ? this.state.server_error : null;
+
+ var self = this;
+
+ var desktopSection;
+ if (this.props.activeSection === 'desktop') {
+ var notifyActive = [false, false, false];
+ if (this.state.notify_level === "mention") {
+ notifyActive[1] = true;
+ } else if (this.state.notify_level === "none") {
+ notifyActive[2] = true;
+ } else {
+ notifyActive[0] = true;
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[0]} onClick={function(){self.handleNotifyRadio("all")}}>For all activity</input>
+ </label>
+ <br/>
+ </div>
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[1]} onClick={function(){self.handleNotifyRadio("mention")}}>Only for mentions and direct messages</input>
+ </label>
+ <br/>
+ </div>
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[2]} onClick={function(){self.handleNotifyRadio("none")}}>Never</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ desktopSection = (
+ <SettingItemMax
+ title="Send desktop notifications"
+ inputs={inputs}
+ submit={this.handleSubmit}
+ server_error={server_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var describe = "";
+ if (this.state.notify_level === "mention") {
+ describe = "Only for mentions and direct messages";
+ } else if (this.state.notify_level === "none") {
+ describe = "Never";
+ } else {
+ describe = "For all activity";
+ }
+
+ desktopSection = (
+ <SettingItemMin
+ title="Send desktop notifications"
+ describe={describe}
+ updateSection={function(){self.props.updateSection("desktop");}}
+ />
+ );
+ }
+
+ var soundSection;
+ if (this.props.activeSection === 'sound') {
+ var soundActive = ["",""];
+ if (this.state.enable_sound === "false") {
+ soundActive[1] = "active";
+ } else {
+ soundActive[0] = "active";
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="btn-group" data-toggle="buttons-radio">
+ <button className={"btn btn-default "+soundActive[0]} onClick={function(){self.handleSoundRadio("true")}}>On</button>
+ <button className={"btn btn-default "+soundActive[1]} onClick={function(){self.handleSoundRadio("false")}}>Off</button>
+ </div>
+ </div>
+ );
+
+ soundSection = (
+ <SettingItemMax
+ title="Desktop notification sounds"
+ inputs={inputs}
+ submit={this.handleSubmit}
+ server_error={server_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var describe = "";
+ if (this.state.enable_sound === "false") {
+ describe = "Off";
+ } else {
+ describe = "On";
+ }
+
+ soundSection = (
+ <SettingItemMin
+ title="Desktop notification sounds"
+ describe={describe}
+ updateSection={function(){self.props.updateSection("sound");}}
+ />
+ );
+ }
+
+ var emailSection;
+ if (this.props.activeSection === 'email') {
+ var emailActive = ["",""];
+ if (this.state.enable_email === "false") {
+ emailActive[1] = "active";
+ } else {
+ emailActive[0] = "active";
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="btn-group" data-toggle="buttons-radio">
+ <button className={"btn btn-default "+emailActive[0]} onClick={function(){self.handleEmailRadio("true")}}>On</button>
+ <button className={"btn btn-default "+emailActive[1]} onClick={function(){self.handleEmailRadio("false")}}>Off</button>
+ </div>
+ <div><br/>{"Email notifications are sent for mentions and direct messages after you have been away from " + config.SiteName + " for 5 minutes."}</div>
+ </div>
+ );
+
+ emailSection = (
+ <SettingItemMax
+ title="Email notifications"
+ inputs={inputs}
+ submit={this.handleSubmit}
+ server_error={server_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var describe = "";
+ if (this.state.enable_email === "false") {
+ describe = "Off";
+ } else {
+ describe = "On";
+ }
+
+ emailSection = (
+ <SettingItemMin
+ title="Email notifications"
+ describe={describe}
+ updateSection={function(){self.props.updateSection("email");}}
+ />
+ );
+ }
+
+ var keysSection;
+ if (this.props.activeSection === 'keys') {
+ var user = this.props.user;
+ var first_name = "";
+ if (user.full_name.length > 0) {
+ first_name = user.full_name.split(' ')[0];
+ }
+
+ var inputs = [];
+
+ if (first_name != "") {
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="checkbox">
+ <label>
+ <input type="checkbox" checked={this.state.first_name_key} onChange={function(e){self.updateFirstNameKey(e.target.checked);}}>{'Your case sensitive first name "' + first_name + '"'}</input>
+ </label>
+ </div>
+ </div>
+ );
+ }
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="checkbox">
+ <label>
+ <input type="checkbox" checked={this.state.username_key} onChange={function(e){self.updateUsernameKey(e.target.checked);}}>{'Your non-case sensitive username "' + user.username + '"'}</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="checkbox">
+ <label>
+ <input type="checkbox" checked={this.state.mention_key} onChange={function(e){self.updateMentionKey(e.target.checked);}}>{'Your username mentioned "@' + user.username + '"'}</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="checkbox">
+ <label>
+ <input ref="customcheck" type="checkbox" checked={this.state.custom_keys_checked} onChange={this.updateCustomMentionKeys}>{'Other non-case sensitive words, separated by commas:'}</input>
+ </label>
+ </div>
+ <input ref="custommentions" className="form-control mentions-input" type="text" defaultValue={this.state.custom_keys} onChange={this.onCustomChange} />
+ </div>
+ );
+
+ keysSection = (
+ <SettingItemMax
+ title="Words that trigger mentions"
+ inputs={inputs}
+ submit={this.handleSubmit}
+ server_error={server_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var keys = [];
+ if (this.state.first_name_key) {
+ var first_name = "";
+ var user = this.props.user;
+ if (user.full_name.length > 0) first_name = user.full_name.split(' ')[0];
+ if (first_name != "") keys.push(first_name);
+ }
+ if (this.state.username_key) keys.push(this.props.user.username);
+ if (this.state.mention_key) keys.push('@'+this.props.user.username);
+ if (this.state.custom_keys.length > 0) keys = keys.concat(this.state.custom_keys.split(','));
+
+ var describe = "";
+ for (var i = 0; i < keys.length; i++) {
+ describe += '"' + keys[i] + '", ';
+ }
+
+ if (describe.length > 0) {
+ describe = describe.substring(0, describe.length - 2);
+ } else {
+ describe = "No words configured";
+ }
+
+ keysSection = (
+ <SettingItemMin
+ title="Words that trigger mentions"
+ describe={describe}
+ updateSection={function(){self.props.updateSection("keys");}}
+ />
+ );
+ }
+
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Notifications</h4>
+ </div>
+ <div ref="wrapper" className="user-settings">
+ <h3 className="tab-header">Notifications</h3>
+ <div className="divider-dark first"/>
+ {desktopSection}
+ <div className="divider-light"/>
+ {soundSection}
+ <div className="divider-light"/>
+ {emailSection}
+ <div className="divider-light"/>
+ {keysSection}
+ <div className="divider-dark"/>
+ </div>
+ </div>
+
+ );
+ }
+});
+
+function getStateFromStoresForSessions() {
+ return {
+ sessions: UserStore.getSessions(),
+ server_error: null,
+ client_error: null
+ };
+}
+
+var SessionsTab = React.createClass({
+ submitRevoke: function(altId) {
+ client.revokeSession(altId,
+ function(data) {
+ AsyncClient.getSessions();
+ }.bind(this),
+ function(err) {
+ state = this.getStateFromStoresForSessions();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ componentDidMount: function() {
+ UserStore.addSessionsChangeListener(this._onChange);
+ AsyncClient.getSessions();
+ },
+ componentWillUnmount: function() {
+ UserStore.removeSessionsChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ this.setState(getStateFromStoresForSessions());
+ },
+ getInitialState: function() {
+ return getStateFromStoresForSessions();
+ },
+ render: function() {
+ var server_error = this.state.server_error ? this.state.server_error : null;
+
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Sessions</h4>
+ </div>
+ <div className="user-settings">
+ <h3 className="tab-header">Sessions</h3>
+ <div className="divider-dark first"/>
+ { server_error }
+ <div className="table-responsive" style={{ maxWidth: "560px", maxHeight: "300px" }}>
+ <table className="table-condensed small">
+ <thead>
+ <tr><th>Id</th><th>Platform</th><th>OS</th><th>Browser</th><th>Created</th><th>Last Activity</th><th>Revoke</th></tr>
+ </thead>
+ <tbody>
+ {
+ this.state.sessions.map(function(value, index) {
+ return (
+ <tr key={ "" + index }>
+ <td style={{ whiteSpace: "nowrap" }}>{ value.alt_id }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{value.props.platform}</td>
+ <td style={{ whiteSpace: "nowrap" }}>{value.props.os}</td>
+ <td style={{ whiteSpace: "nowrap" }}>{value.props.browser}</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ new Date(value.create_at).toLocaleString() }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ new Date(value.last_activity_at).toLocaleString() }</td>
+ <td><button onClick={this.submitRevoke.bind(this, value.alt_id)} className="pull-right btn btn-primary">Revoke</button></td>
+ </tr>
+ );
+ }, this)
+ }
+ </tbody>
+ </table>
+ </div>
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+function getStateFromStoresForAudits() {
+ return {
+ audits: UserStore.getAudits()
+ };
+}
+
+var AuditTab = React.createClass({
+ componentDidMount: function() {
+ UserStore.addAuditsChangeListener(this._onChange);
+ AsyncClient.getAudits();
+ },
+ componentWillUnmount: function() {
+ UserStore.removeAuditsChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ this.setState(getStateFromStoresForAudits());
+ },
+ getInitialState: function() {
+ return getStateFromStoresForAudits();
+ },
+ render: function() {
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Activity Log</h4>
+ </div>
+ <div className="user-settings">
+ <h3 className="tab-header">Activity Log</h3>
+ <div className="divider-dark first"/>
+ <div className="table-responsive" style={{ maxWidth: "560px", maxHeight: "300px" }}>
+ <table className="table-condensed small">
+ <thead>
+ <tr>
+ <th>Time</th>
+ <th>Action</th>
+ <th>IP Address</th>
+ <th>Session</th>
+ <th>Other Info</th>
+ </tr>
+ </thead>
+ <tbody>
+ {
+ this.state.audits.map(function(value, index) {
+ return (
+ <tr key={ "" + index }>
+ <td style={{ whiteSpace: "nowrap" }}>{ new Date(value.create_at).toLocaleString() }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ value.action.replace("/api/v1", "") }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ value.ip_address }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ value.session_id }</td>
+ <td style={{ whiteSpace: "nowrap" }}>{ value.extra_info }</td>
+ </tr>
+ );
+ }, this)
+ }
+ </tbody>
+ </table>
+ </div>
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+var SecurityTab = React.createClass({
+ submitPassword: function(e) {
+ e.preventDefault();
+
+ var user = UserStore.getCurrentUser();
+ var currentPassword = this.state.current_password;
+ var newPassword = this.state.new_password;
+ var confirmPassword = this.state.confirm_password;
+
+ if (currentPassword === '') {
+ this.setState({ password_error: "Please enter your current password" });
+ return;
+ }
+
+ if (newPassword.length < 5) {
+ this.setState({ password_error: "New passwords must be at least 5 characters" });
+ return;
+ }
+
+ if (newPassword != confirmPassword) {
+ this.setState({ password_error: "The new passwords you entered do not match" });
+ return;
+ }
+
+ var data = {};
+ data.user_id = user.id;
+ data.current_password = currentPassword;
+ data.new_password = newPassword;
+
+ client.updatePassword(data,
+ function(data) {
+ this.updateSection("");
+ AsyncClient.getMe();
+ this.setState({ current_password: '', new_password: '', confirm_password: '' });
+ }.bind(this),
+ function(err) {
+ state = this.getInitialState();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ updateCurrentPassword: function(e) {
+ this.setState({ current_password: e.target.value });
+ },
+ updateNewPassword: function(e) {
+ this.setState({ new_password: e.target.value });
+ },
+ updateConfirmPassword: function(e) {
+ this.setState({ confirm_password: e.target.value });
+ },
+ getInitialState: function() {
+ return { current_password: '', new_password: '', confirm_password: '' };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? this.state.server_error : null;
+ var password_error = this.state.password_error ? this.state.password_error : null;
+
+ var passwordSection;
+ var self = this;
+ if (this.props.activeSection === 'password') {
+ var inputs = [];
+
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">Current Password</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="password" onChange={this.updateCurrentPassword} value={this.state.current_password}/>
+ </div>
+ </div>
+ );
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">New Password</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="password" onChange={this.updateNewPassword} value={this.state.new_password}/>
+ </div>
+ </div>
+ );
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">Retype New Password</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="password" onChange={this.updateConfirmPassword} value={this.state.confirm_password}/>
+ </div>
+ </div>
+ );
+
+ passwordSection = (
+ <SettingItemMax
+ title="Password"
+ inputs={inputs}
+ submit={this.submitPassword}
+ server_error={server_error}
+ client_error={password_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var d = new Date(this.props.user.last_password_update);
+ var hour = d.getHours() < 10 ? "0" + d.getHours() : String(d.getHours());
+ var min = d.getMinutes() < 10 ? "0" + d.getMinutes() : String(d.getMinutes());
+ var dateStr = "Last updated " + Constants.MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() + " at " + hour + ":" + min;
+
+ passwordSection = (
+ <SettingItemMin
+ title="Password"
+ describe={dateStr}
+ updateSection={function(){self.props.updateSection("password");}}
+ />
+ );
+ }
+
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Security Settings</h4>
+ </div>
+ <div className="user-settings">
+ <h3 className="tab-header">Security Settings</h3>
+ <div className="divider-dark first"/>
+ { passwordSection }
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+var GeneralTab = React.createClass({
+ submitActive: false,
+ submitUsername: function(e) {
+ e.preventDefault();
+
+ var user = this.props.user;
+ var username = this.state.username.trim();
+
+ var username_error = utils.isValidUsername(username);
+ if (username_error === "Cannot use a reserved word as a username.") {
+ this.setState({client_error: "This username is reserved, please choose a new one." });
+ return;
+ } else if (username_error) {
+ this.setState({client_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." });
+ return;
+ }
+
+ if (user.username === username) {
+ this.setState({client_error: "You must submit a new username"});
+ return;
+ }
+
+ user.username = username;
+
+ this.submitUser(user);
+ },
+ submitName: function(e) {
+ e.preventDefault();
+
+ var user = UserStore.getCurrentUser();
+ var firstName = this.state.first_name.trim();
+ var lastName = this.state.last_name.trim();
+
+ var fullName = firstName + ' ' + lastName;
+
+ if (user.full_name === fullName) {
+ this.setState({client_error: "You must submit a new name"})
+ return;
+ }
+
+ user.full_name = fullName;
+
+ this.submitUser(user);
+ },
+ submitEmail: function(e) {
+ e.preventDefault();
+
+ var user = UserStore.getCurrentUser();
+ var email = this.state.email.trim().toLowerCase();
+
+ if (user.email === email) {
+ return;
+ }
+
+ if (email === '' || !utils.isEmail(email)) {
+ this.setState({ email_error: "Please enter a valid email address" });
+ return;
+ }
+
+ user.email = email;
+
+ this.submitUser(user);
+ },
+ submitUser: function(user) {
+ client.updateUser(user,
+ function(data) {
+ this.updateSection("");
+ AsyncClient.getMe();
+ }.bind(this),
+ function(err) {
+ state = this.getInitialState();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ submitPicture: function(e) {
+ e.preventDefault();
+
+ if (!this.state.picture) return;
+
+ if(!this.submitActive) return;
+
+ formData = new FormData();
+ formData.append('image', this.state.picture, this.state.picture.name);
+
+ client.uploadProfileImage(formData,
+ function(data) {
+ this.submitActive = false;
+ window.location.reload();
+ }.bind(this),
+ function(err) {
+ state = this.getInitialState();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ updateUsername: function(e) {
+ this.setState({ username: e.target.value });
+ },
+ updateFirstName: function(e) {
+ this.setState({ first_name: e.target.value });
+ },
+ updateLastName: function(e) {
+ this.setState({ last_name: e.target.value});
+ },
+ updateEmail: function(e) {
+ this.setState({ email: e.target.value});
+ },
+ updatePicture: function(e) {
+ if (e.target.files && e.target.files[0]) {
+ this.setState({ picture: e.target.files[0] });
+ } else {
+ this.setState({ picture: null });
+ }
+
+ this.submitActive = true
+ },
+ updateSection: function(section) {
+ this.setState({client_error:""})
+ this.submitActive = false
+ this.props.updateSection(section);
+ },
+ getInitialState: function() {
+ var user = this.props.user;
+
+ var splitStr = user.full_name.split(' ');
+ var firstName = splitStr.shift();
+ var lastName = splitStr.join(' ');
+
+ return { username: user.username, first_name: firstName, last_name: lastName,
+ email: user.email, picture: null };
+ },
+ render: function() {
+ var user = this.props.user;
+
+ var client_error = this.state.client_error ? this.state.client_error : null;
+ var server_error = this.state.server_error ? this.state.server_error : null;
+ var email_error = this.state.email_error ? this.state.email_error : null;
+
+ var nameSection;
+ var self = this;
+
+ if (this.props.activeSection === 'name') {
+ var inputs = [];
+
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">First Name</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="text" onChange={this.updateFirstName} value={this.state.first_name}/>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">Last Name</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="text" onChange={this.updateLastName} value={this.state.last_name}/>
+ </div>
+ </div>
+ );
+
+ nameSection = (
+ <SettingItemMax
+ title="Name"
+ inputs={inputs}
+ submit={this.submitName}
+ server_error={server_error}
+ client_error={client_error}
+ updateSection={function(e){self.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ nameSection = (
+ <SettingItemMin
+ title="Name"
+ describe={UserStore.getCurrentUser().full_name}
+ updateSection={function(){self.updateSection("name");}}
+ />
+ );
+ }
+
+ var usernameSection;
+ if (this.props.activeSection === 'username') {
+ var inputs = [];
+
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">{utils.isMobile() ? "": "Username"}</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="text" onChange={this.updateUsername} value={this.state.username}/>
+ </div>
+ </div>
+ );
+
+ usernameSection = (
+ <SettingItemMax
+ title="Username"
+ inputs={inputs}
+ submit={this.submitUsername}
+ server_error={server_error}
+ client_error={client_error}
+ updateSection={function(e){self.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ usernameSection = (
+ <SettingItemMin
+ title="Username"
+ describe={UserStore.getCurrentUser().username}
+ updateSection={function(){self.updateSection("username");}}
+ />
+ );
+ }
+ var emailSection;
+ if (this.props.activeSection === 'email') {
+ var inputs = [];
+
+ inputs.push(
+ <div>
+ <label className="col-sm-5 control-label">Primary Email</label>
+ <div className="col-sm-7">
+ <input className="form-control" type="text" onChange={this.updateEmail} value={this.state.email}/>
+ </div>
+ </div>
+ );
+
+ emailSection = (
+ <SettingItemMax
+ title="Email"
+ inputs={inputs}
+ submit={this.submitEmail}
+ server_error={server_error}
+ client_error={email_error}
+ updateSection={function(e){self.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ emailSection = (
+ <SettingItemMin
+ title="Email"
+ describe={UserStore.getCurrentUser().email}
+ updateSection={function(){self.updateSection("email");}}
+ />
+ );
+ }
+
+ var pictureSection;
+ if (this.props.activeSection === 'picture') {
+ pictureSection = (
+ <SettingPicture
+ title="Profile Picture"
+ submit={this.submitPicture}
+ src={"/api/v1/users/" + user.id + "/image"}
+ server_error={server_error}
+ client_error={email_error}
+ updateSection={function(e){self.updateSection("");e.preventDefault();}}
+ picture={this.state.picture}
+ pictureChange={this.updatePicture}
+ submitActive={this.submitActive}
+ />
+ );
+
+ } else {
+ pictureSection = (
+ <SettingItemMin
+ title="Profile Picture"
+ describe="Picture inside."
+ updateSection={function(){self.updateSection("picture");}}
+ />
+ );
+ }
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>General Settings</h4>
+ </div>
+ <div className="user-settings">
+ <h3 className="tab-header">General Settings</h3>
+ <div className="divider-dark first"/>
+ {nameSection}
+ <div className="divider-light"/>
+ {usernameSection}
+ <div className="divider-light"/>
+ {emailSection}
+ <div className="divider-light"/>
+ {pictureSection}
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+
+var AppearanceTab = React.createClass({
+ submitTheme: function(e) {
+ e.preventDefault();
+ var user = UserStore.getCurrentUser();
+ if (!user.props) user.props = {};
+ user.props.theme = this.state.theme;
+
+ client.updateUser(user,
+ function(data) {
+ this.props.updateSection("");
+ window.location.reload();
+ }.bind(this),
+ function(err) {
+ state = this.getInitialState();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ updateTheme: function(e) {
+ var hex = utils.rgb2hex(e.target.style.backgroundColor);
+ this.setState({ theme: hex.toLowerCase() });
+ },
+ componentDidMount: function() {
+ if (this.props.activeSection === "theme") {
+ $(this.refs[this.state.theme].getDOMNode()).addClass('active-border');
+ }
+ },
+ componentDidUpdate: function() {
+ if (this.props.activeSection === "theme") {
+ $('.color-btn').removeClass('active-border');
+ $(this.refs[this.state.theme].getDOMNode()).addClass('active-border');
+ }
+ },
+ getInitialState: function() {
+ var user = UserStore.getCurrentUser();
+ var theme = config.ThemeColors != null ? config.ThemeColors[0] : "#2389d7";
+ if (user.props && user.props.theme) {
+ theme = user.props.theme;
+ }
+ return { theme: theme.toLowerCase() };
+ },
+ render: function() {
+ var server_error = this.state.server_error ? this.state.server_error : null;
+
+
+ var themeSection;
+ var self = this;
+
+ if (config.ThemeColors != null) {
+ if (this.props.activeSection === 'theme') {
+ var theme_buttons = [];
+
+ for (var i = 0; i < config.ThemeColors.length; i++) {
+ theme_buttons.push(<button ref={config.ThemeColors[i]} type="button" className="btn btn-lg color-btn" style={{backgroundColor: config.ThemeColors[i]}} onClick={this.updateTheme} />);
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <li className="row setting-list-item form-group">
+ <div className="btn-group" data-toggle="buttons-radio">
+ { theme_buttons }
+ </div>
+ </li>
+ );
+
+ themeSection = (
+ <SettingItemMax
+ title="Theme"
+ inputs={inputs}
+ submit={this.submitTheme}
+ server_error={server_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault;}}
+ />
+ );
+ } else {
+ themeSection = (
+ <SettingItemMin
+ title="Theme"
+ describe={this.state.theme}
+ updateSection={function(){self.props.updateSection("theme");}}
+ />
+ );
+ }
+ }
+
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Appearance Settings</h4>
+ </div>
+ <div className="user-settings">
+ <h3 className="tab-header">Appearance Settings</h3>
+ <div className="divider-dark first"/>
+ {themeSection}
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ UserStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ UserStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function () {
+ var user = UserStore.getCurrentUser();
+ if (!utils.areStatesEqual(this.state.user, user)) {
+ this.setState({ user: user });
+ }
+ },
+ getInitialState: function() {
+ return { user: UserStore.getCurrentUser() };
+ },
+ render: function() {
+ if (this.props.activeTab === 'general') {
+ return (
+ <div>
+ <GeneralTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else if (this.props.activeTab === 'security') {
+ return (
+ <div>
+ <SecurityTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else if (this.props.activeTab === 'notifications') {
+ return (
+ <div>
+ <NotificationsTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else if (this.props.activeTab === 'sessions') {
+ return (
+ <div>
+ <SessionsTab activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else if (this.props.activeTab === 'activity_log') {
+ return (
+ <div>
+ <AuditTab activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else if (this.props.activeTab === 'appearance') {
+ return (
+ <div>
+ <AppearanceTab activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
new file mode 100644
index 000000000..7d0f0d8a9
--- /dev/null
+++ b/web/react/components/view_image.jsx
@@ -0,0 +1,189 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+var utils = require('../utils/utils.jsx');
+
+module.exports = React.createClass({
+ handleNext: function() {
+ var id = this.state.imgId + 1;
+ if (id > this.props.filenames.length-1) {
+ id = 0;
+ }
+ this.setState({ imgId: id });
+ this.loadImage(id);
+ },
+ handlePrev: function() {
+ var id = this.state.imgId - 1;
+ if (id < 0) {
+ id = this.props.filenames.length-1;
+ }
+ this.setState({ imgId: id });
+ this.loadImage(id);
+ },
+ componentWillReceiveProps: function(nextProps) {
+ this.setState({ imgId: nextProps.startId });
+ },
+ loadImage: function(id) {
+ if (this.state.loaded[id] || this.state.images[id]) return;
+
+ var src = "";
+ if (this.props.imgCount > 0) {
+ src = this.props.filenames[id];
+ } else {
+ var fileInfo = utils.splitFileLocation(this.props.filenames[id]);
+ src = fileInfo['path'] + '_preview.jpg';
+ }
+
+ var self = this;
+ var img = new Image();
+ img.load(src,
+ function(){
+ var progress = self.state.progress;
+ progress[id] = img.completedPercentage;
+ self.setState({ progress: progress });
+ });
+ img.onload = function(imgid) {
+ return function() {
+ var loaded = self.state.loaded;
+ loaded[imgid] = true;
+ self.setState({ loaded: loaded });
+ };
+ }(id);
+ var images = this.state.images;
+ images[id] = img;
+ this.setState({ images: images });
+ },
+ componentDidUpdate: function() {
+ if (this.refs.image) {
+ var height = $(window).height()-100;
+ if (this.state.loaded[this.state.imgId]) {
+ $(this.refs.imageWrap.getDOMNode()).removeClass("default");
+ $(this.refs.image.getDOMNode()).css("max-height",height);
+ }
+ }
+ },
+ componentDidMount: function() {
+ var self = this;
+ $("#"+this.props.modalId).on('shown.bs.modal', function() {
+ self.setState({ viewed: true });
+ self.loadImage(self.state.imgId);
+ })
+
+ $(this.refs.modal.getDOMNode()).click(function(e){
+ if (e.target == this || e.target == self.refs.imageBody.getDOMNode()) {
+ $('.image_modal').modal('hide');
+ }
+ });
+
+ $(this.refs.imageWrap.getDOMNode()).hover(
+ function() {
+ $(self.refs.imageFooter.getDOMNode()).addClass("footer--show");
+ }, function() {
+ $(self.refs.imageFooter.getDOMNode()).removeClass("footer--show");
+ }
+ );
+ },
+ getPublicLink: function(e) {
+ data = {};
+ data["channel_id"] = this.props.channelId;
+ data["user_id"] = this.props.userId;
+ data["filename"] = this.props.filenames[this.state.imgId];
+ Client.getPublicLink(data,
+ function(data) {
+ window.open(data["public_link"]);
+ }.bind(this),
+ function(err) {
+ }.bind(this)
+ );
+ },
+ getInitialState: function() {
+ var loaded = [];
+ var progress = [];
+ for (var i = 0; i < this.props.filenames.length; i ++) {
+ loaded.push(false);
+ progress.push(0);
+ }
+ return { imgId: this.props.startId, viewed: false, loaded: loaded, progress: progress, images: {} };
+ },
+ render: function() {
+ if (this.props.filenames.length < 1 || this.props.filenames.length-1 < this.state.imgId) {
+ return <div/>;
+ }
+
+ var fileInfo = utils.splitFileLocation(this.props.filenames[this.state.imgId]);
+
+ var name = fileInfo['name'] + '.' + fileInfo['ext'];
+
+ var loading = "";
+ var bgClass = "";
+ var img = {};
+ if (!this.state.loaded[this.state.imgId]) {
+ var percentage = Math.floor(this.state.progress[this.state.imgId]);
+ loading = (
+ <div key={name+"_loading"}>
+ <img ref="placeholder" className="loader-image" src="/static/images/load.gif" />
+ { percentage > 0 ?
+ <span className="loader-percent" >{"Downloading " + percentage + "%"}</span>
+ : ""}
+ </div>
+ );
+ bgClass = "black-bg";
+ } else if (this.state.viewed) {
+ for (var id in this.state.images) {
+ var info = utils.splitFileLocation(this.props.filenames[id]);
+ var preview_filename = "";
+ if (this.props.imgCount > 0) {
+ preview_filename = this.props.filenames[this.state.imgId];
+ } else {
+ preview_filename = info['path'] + '_preview.jpg';
+ }
+
+ var imgClass = "hidden";
+ if (this.state.loaded[id] && this.state.imgId == id) imgClass = "";
+
+ img[info['path']] = <a key={info['path']} className={imgClass} href={this.props.filenames[id]} target="_blank"><img ref="image" src={preview_filename}/></a>;
+ }
+ }
+
+ var imgFragment = React.addons.createFragment(img);
+
+ return (
+ <div className="modal fade image_modal" ref="modal" id={this.props.modalId} tabIndex="-1" role="dialog" aria-hidden="true">
+ <div className="modal-dialog modal-image">
+ <div className="modal-content image-content">
+ <div ref="imageBody" className="modal-body image-body">
+ <div ref="imageWrap" className={"image-wrapper default " + bgClass}>
+ <div className="modal-close" data-dismiss="modal"></div>
+ {imgFragment}
+ <div ref="imageFooter" className="modal-button-bar">
+ <span className="pull-left text">{"Image "+(this.state.imgId+1)+" of "+this.props.filenames.length}</span>
+ <div className="image-links">
+ { config.AllowPublicLink ?
+ <div>
+ <a href="#" className="text" data-title="Public Image" onClick={this.getPublicLink}>Get Public Link</a>
+ <span className="text"> | </span>
+ </div>
+ : "" }
+ <a href={this.props.filenames[id]} download={name} className="text">Download</a>
+ </div>
+ </div>
+ {loading}
+ </div>
+ { this.props.filenames.length > 1 ?
+ <a className="modal-prev-bar" href="#" onClick={this.handlePrev}>
+ <i className="image-control image-prev"/>
+ </a>
+ : "" }
+ { this.props.filenames.length > 1 ?
+ <a className="modal-next-bar" href="#" onClick={this.handleNext}>
+ <i className="image-control image-next"/>
+ </a>
+ : "" }
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
diff --git a/web/react/dispatcher/app_dispatcher.jsx b/web/react/dispatcher/app_dispatcher.jsx
new file mode 100644
index 000000000..4ae28e8eb
--- /dev/null
+++ b/web/react/dispatcher/app_dispatcher.jsx
@@ -0,0 +1,30 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Dispatcher = require('flux').Dispatcher;
+var assign = require('object-assign');
+
+var Constants = require('../utils/constants.jsx');
+var PayloadSources = Constants.PayloadSources;
+
+var AppDispatcher = assign(new Dispatcher(), {
+
+ handleServerAction: function(action) {
+ var payload = {
+ source: PayloadSources.SERVER_ACTION,
+ action: action
+ };
+ this.dispatch(payload);
+ },
+
+ handleViewAction: function(action) {
+ var payload = {
+ source: PayloadSources.VIEW_ACTION,
+ action: action
+ };
+ this.dispatch(payload);
+ }
+
+});
+
+module.exports = AppDispatcher;
diff --git a/web/react/package.json b/web/react/package.json
new file mode 100644
index 000000000..055530fea
--- /dev/null
+++ b/web/react/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "mattermost",
+ "version": "0.0.1",
+ "private": true,
+ "dependencies": {
+ "flux": "^2.0.0",
+ "keymirror": "~0.1.0",
+ "object-assign": "^1.0.0",
+ "react": "^0.12.0",
+ "autolinker": "^0.15.2",
+ "linkify-it": "^1.1.0",
+ "react-zeroclipboard-mixin": "^0.1.0"
+ },
+ "devDependencies": {
+ "browserify": "^6.2.0",
+ "envify": "^3.0.0",
+ "jest-cli": "~0.1.17",
+ "reactify": "^0.15.2",
+ "uglify-js": "~2.4.15",
+ "watchify": "^2.1.1"
+ },
+ "scripts": {
+ "start": "watchify --extension=jsx -o ../static/js/bundle.js -v -d ./**/*.jsx",
+ "build": "NODE_ENV=production browserify ./**/*.jsx | uglifyjs > ../static/js/bundle.min.js",
+ "build_old": "NODE_ENV=production browserify ./**/*.jsx | uglifyjs -cm > ../static/js/bundle.min.js",
+ "test": "jest"
+ },
+ "browserify": {
+ "transform": [
+ "reactify",
+ "envify"
+ ]
+ },
+ "jest": {
+ "rootDir": "."
+ }
+}
diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx
new file mode 100644
index 000000000..df67d4360
--- /dev/null
+++ b/web/react/pages/channel.jsx
@@ -0,0 +1,197 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Navbar = require('../components/navbar.jsx');
+var Sidebar = require('../components/sidebar.jsx');
+var ChannelHeader = require('../components/channel_header.jsx');
+var PostList = require('../components/post_list.jsx');
+var CreatePost = require('../components/create_post.jsx');
+var SidebarRight = require('../components/sidebar_right.jsx');
+var SidebarRightMenu = require('../components/sidebar_right_menu.jsx');
+var GetLinkModal = require('../components/get_link_modal.jsx');
+var MemberInviteModal = require('../components/invite_member_modal.jsx');
+var EditChannelModal = require('../components/edit_channel_modal.jsx');
+var DeleteChannelModal = require('../components/delete_channel_modal.jsx');
+var RenameChannelModal = require('../components/rename_channel_modal.jsx');
+var RenameTeamModal = require('../components/rename_team_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/settings_modal.jsx');
+var ChannelMembersModal = require('../components/channel_members.jsx');
+var ChannelInviteModal = require('../components/channel_invite_modal.jsx');
+var TeamMembersModal = require('../components/team_members.jsx');
+var DirectChannelModal = require('../components/more_direct_channels.jsx');
+var ErrorBar = require('../components/error_bar.jsx')
+var ChannelLoader = require('../components/channel_loader.jsx');
+var MentionList = require('../components/mention_list.jsx');
+var ChannelInfoModal = require('../components/channel_info_modal.jsx');
+
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+global.window.setup_channel_page = function(team_name, team_type, channel_name, channel_id) {
+
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.CLICK_CHANNEL,
+ name: channel_name,
+ id: channel_id
+ });
+
+ React.render(
+ <ErrorBar/>,
+ document.getElementById('error_bar')
+ );
+
+ React.render(
+ <ChannelLoader/>,
+ document.getElementById('channel_loader')
+ );
+
+ React.render(
+ <Navbar teamName={team_name} />,
+ document.getElementById('navbar')
+ );
+
+ React.render(
+ <Sidebar teamName={team_name} teamType={team_type} />,
+ document.getElementById('sidebar-left')
+ );
+
+ React.render(
+ <RenameTeamModal teamName={team_name} />,
+ document.getElementById('rename_team_modal')
+ );
+
+ React.render(
+ <GetLinkModal />,
+ document.getElementById('get_link_modal')
+ );
+
+ React.render(
+ <UserSettingsModal />,
+ document.getElementById('user_settings_modal')
+ );
+
+ React.render(
+ <TeamMembersModal teamName={team_name} />,
+ document.getElementById('team_members_modal')
+ );
+
+ React.render(
+ <MemberInviteModal />,
+ document.getElementById('invite_member_modal')
+ );
+
+ React.render(
+ <ChannelHeader />,
+ document.getElementById('channel-header')
+ );
+
+ React.render(
+ <EditChannelModal />,
+ document.getElementById('edit_channel_modal')
+ );
+
+ React.render(
+ <DeleteChannelModal />,
+ document.getElementById('delete_channel_modal')
+ );
+
+ React.render(
+ <RenameChannelModal />,
+ document.getElementById('rename_channel_modal')
+ );
+
+ React.render(
+ <ChannelNotificationsModal />,
+ document.getElementById('channel_notifications_modal')
+ );
+
+ React.render(
+ <ChannelMembersModal />,
+ document.getElementById('channel_members_modal')
+ );
+
+ React.render(
+ <ChannelInviteModal />,
+ document.getElementById('channel_invite_modal')
+ );
+
+ React.render(
+ <ChannelInfoModal />,
+ document.getElementById('channel_info_modal')
+ );
+
+ React.render(
+ <MoreChannelsModal />,
+ document.getElementById('more_channels_modal')
+ );
+
+ React.render(
+ <DirectChannelModal />,
+ document.getElementById('direct_channel_modal')
+ );
+
+ React.render(
+ <NewChannelModal />,
+ document.getElementById('new_channel_modal')
+ );
+
+ React.render(
+ <PostList />,
+ document.getElementById('post-list')
+ );
+
+ React.render(
+ <EditPostModal />,
+ document.getElementById('edit_post_modal')
+ );
+
+ React.render(
+ <DeletePostModal />,
+ document.getElementById('delete_post_modal')
+ );
+
+ React.render(
+ <PostDeletedModal />,
+ document.getElementById('post_deleted_modal')
+ );
+
+ React.render(
+ <CreatePost />,
+ document.getElementById('post-create')
+ );
+
+ React.render(
+ <SidebarRight />,
+ document.getElementById('sidebar-right')
+ );
+
+ React.render(
+ <SidebarRightMenu teamName={team_name} teamType={team_type} />,
+ document.getElementById('sidebar-menu')
+ );
+
+ React.render(
+ <MentionList id="post_textbox" />,
+ document.getElementById('post_mention_tab')
+ );
+
+ React.render(
+ <MentionList id="reply_textbox" />,
+ document.getElementById('reply_mention_tab')
+ );
+
+ React.render(
+ <MentionList id="edit_textbox" />,
+ document.getElementById('edit_mention_tab')
+ );
+
+};
diff --git a/web/react/pages/find_team.jsx b/web/react/pages/find_team.jsx
new file mode 100644
index 000000000..5346c0cf0
--- /dev/null
+++ b/web/react/pages/find_team.jsx
@@ -0,0 +1,13 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var FindTeam = require('../components/find_team.jsx');
+
+global.window.setup_find_team_page = function() {
+
+ React.render(
+ <FindTeam />,
+ document.getElementById('find-team')
+ );
+
+};
diff --git a/web/react/pages/home.jsx b/web/react/pages/home.jsx
new file mode 100644
index 000000000..08dd32f73
--- /dev/null
+++ b/web/react/pages/home.jsx
@@ -0,0 +1,14 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var Constants = require('../utils/constants.jsx');
+
+global.window.setup_home_page = function() {
+ var last = ChannelStore.getLastVisitedName();
+ if (last == null || last.length === 0) {
+ window.location.replace("/channels/" + Constants.DEFAULT_CHANNEL);
+ } else {
+ window.location.replace("/channels/" + last);
+ }
+}
diff --git a/web/react/pages/login.jsx b/web/react/pages/login.jsx
new file mode 100644
index 000000000..a4e6b438e
--- /dev/null
+++ b/web/react/pages/login.jsx
@@ -0,0 +1,11 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Login = require('../components/login.jsx');
+
+global.window.setup_login_page = function() {
+ React.render(
+ <Login />,
+ document.getElementById('login')
+ );
+};
diff --git a/web/react/pages/password_reset.jsx b/web/react/pages/password_reset.jsx
new file mode 100644
index 000000000..6d0d88a10
--- /dev/null
+++ b/web/react/pages/password_reset.jsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PasswordReset = require('../components/password_reset.jsx');
+
+global.window.setup_password_reset_page = function(is_reset, team_name, domain, hash, data) {
+
+ React.render(
+ <PasswordReset
+ isReset={is_reset}
+ teamName={team_name}
+ domain={domain}
+ hash={hash}
+ data={data}
+ />,
+ document.getElementById('reset')
+ );
+
+};
diff --git a/web/react/pages/signup_team.jsx b/web/react/pages/signup_team.jsx
new file mode 100644
index 000000000..e982f5a79
--- /dev/null
+++ b/web/react/pages/signup_team.jsx
@@ -0,0 +1,11 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SignupTeam =require('../components/signup_team.jsx');
+
+global.window.setup_signup_team_page = function() {
+ React.render(
+ <SignupTeam />,
+ document.getElementById('signup-team')
+ );
+}; \ No newline at end of file
diff --git a/web/react/pages/signup_team_complete.jsx b/web/react/pages/signup_team_complete.jsx
new file mode 100644
index 000000000..c17cbdfac
--- /dev/null
+++ b/web/react/pages/signup_team_complete.jsx
@@ -0,0 +1,11 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SignupTeamComplete =require('../components/signup_team_complete.jsx');
+
+global.window.setup_signup_team_complete_page = function(email, name, data, hash) {
+ React.render(
+ <SignupTeamComplete name={name} email={email} hash={hash} data={data} />,
+ document.getElementById('signup-team-complete')
+ );
+}; \ No newline at end of file
diff --git a/web/react/pages/signup_user_complete.jsx b/web/react/pages/signup_user_complete.jsx
new file mode 100644
index 000000000..a24c8d4c8
--- /dev/null
+++ b/web/react/pages/signup_user_complete.jsx
@@ -0,0 +1,11 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SignupUserComplete =require('../components/signup_user_complete.jsx');
+
+global.window.setup_signup_user_complete_page = function(email, domain, name, id, data, hash) {
+ React.render(
+ <SignupUserComplete team_id={id} domain={domain} team_name={name} email={email} hash={hash} data={data} />,
+ document.getElementById('signup-user-complete')
+ );
+}; \ No newline at end of file
diff --git a/web/react/pages/verify.jsx b/web/react/pages/verify.jsx
new file mode 100644
index 000000000..69850849f
--- /dev/null
+++ b/web/react/pages/verify.jsx
@@ -0,0 +1,13 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var EmailVerify = require('../components/email_verify.jsx');
+
+global.window.setup_verify_page = function(is_verified) {
+
+ React.render(
+ <EmailVerify isVerified={is_verified} />,
+ document.getElementById('verify')
+ );
+
+};
diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx
new file mode 100644
index 000000000..3f259bc7d
--- /dev/null
+++ b/web/react/stores/channel_store.jsx
@@ -0,0 +1,255 @@
+// 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 assign = require('object-assign');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+
+var CHANGE_EVENT = 'change';
+var MORE_CHANGE_EVENT = 'change';
+var EXTRA_INFO_EVENT = 'extra_info';
+
+var ChannelStore = assign({}, EventEmitter.prototype, {
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+ emitMoreChange: function() {
+ this.emit(MORE_CHANGE_EVENT);
+ },
+ addMoreChangeListener: function(callback) {
+ this.on(MORE_CHANGE_EVENT, callback);
+ },
+ removeMoreChangeListener: function(callback) {
+ this.removeListener(MORE_CHANGE_EVENT, callback);
+ },
+ emitExtraInfoChange: function() {
+ this.emit(EXTRA_INFO_EVENT);
+ },
+ addExtraInfoChangeListener: function(callback) {
+ this.on(EXTRA_INFO_EVENT, callback);
+ },
+ removeExtraInfoChangeListener: function(callback) {
+ this.removeListener(EXTRA_INFO_EVENT, callback);
+ },
+ get: function(id) {
+ var current = null;
+ var c = this._getChannels();
+
+ c.some(function(channel) {
+ if (channel.id == id) {
+ current = channel;
+ return true;
+ }
+ return false;
+ });
+
+ return current;
+ },
+ getMember: function(id) {
+ var current = null;
+ return this.getAllMembers()[id];
+ },
+ getByName: function(name) {
+ var current = null;
+ var c = this._getChannels();
+
+ c.some(function(channel) {
+ if (channel.name == name) {
+ current = channel;
+ return true;
+ }
+
+ return false;
+
+ });
+
+ return current;
+
+ },
+ getAll: function() {
+ return this._getChannels();
+ },
+ getAllMembers: function() {
+ return this._getChannelMembers();
+ },
+ getMoreAll: function() {
+ return this._getMoreChannels();
+ },
+ setCurrentId: function(id) {
+ if (id == null)
+ sessionStorage.removeItem("current_channel_id");
+ else
+ sessionStorage.setItem("current_channel_id", id);
+ },
+ setLastVisitedName: function(name) {
+ if (name == null)
+ localStorage.removeItem("last_visited_name");
+ else
+ localStorage.setItem("last_visited_name", name);
+ },
+ getLastVisitedName: function() {
+ return localStorage.getItem("last_visited_name");
+ },
+ resetCounts: function(id) {
+ var cm = this._getChannelMembers();
+ for (var cmid in cm) {
+ if (cm[cmid].channel_id == id) {
+ var c = this.get(id);
+ if (c) {
+ cm[cmid].msg_count = this.get(id).total_msg_count;
+ cm[cmid].mention_count = 0;
+ }
+ break;
+ }
+ }
+ this._storeChannelMembers(cm);
+ },
+ getCurrentId: function() {
+ return sessionStorage.getItem("current_channel_id");
+ },
+ getCurrent: function() {
+ var currentId = ChannelStore.getCurrentId();
+
+ if (currentId != null)
+ return this.get(currentId);
+ else
+ return null;
+ },
+ getCurrentMember: function() {
+ var currentId = ChannelStore.getCurrentId();
+
+ if (currentId != null)
+ return this.getAllMembers()[currentId];
+ else
+ return null;
+ },
+ setChannelMember: function(member) {
+ var members = this._getChannelMembers();
+ members[member.channel_id] = member;
+ this._storeChannelMembers(members);
+ this.emitChange();
+ },
+ getCurrentExtraInfo: function() {
+ var currentId = ChannelStore.getCurrentId();
+ var extra = null;
+
+ if (currentId != null)
+ extra = this._getExtraInfos()[currentId];
+
+ if (extra == null)
+ extra = {members: []};
+
+ return extra;
+ },
+ getExtraInfo: function(channel_id) {
+ var extra = null;
+
+ if (channel_id != null)
+ extra = this._getExtraInfos()[channel_id];
+
+ if (extra == null)
+ extra = {members: []};
+
+ return extra;
+ },
+ _storeChannels: function(channels) {
+ sessionStorage.setItem("channels", JSON.stringify(channels));
+ },
+ _getChannels: function() {
+ var channels = [];
+ try {
+ channels = JSON.parse(sessionStorage.channels);
+ }
+ catch (err) {
+ }
+
+ return channels;
+ },
+ _storeChannelMembers: function(channelMembers) {
+ sessionStorage.setItem("channel_members", JSON.stringify(channelMembers));
+ },
+ _getChannelMembers: function() {
+ var members = {};
+ try {
+ members = JSON.parse(sessionStorage.channel_members);
+ }
+ catch (err) {
+ }
+
+ return members;
+ },
+ _storeMoreChannels: function(channels) {
+ sessionStorage.setItem("more_channels", JSON.stringify(channels));
+ },
+ _getMoreChannels: function() {
+ var channels = [];
+ try {
+ channels = JSON.parse(sessionStorage.more_channels);
+ }
+ catch (err) {
+ }
+
+ return channels;
+ },
+ _storeExtraInfos: function(extraInfos) {
+ sessionStorage.setItem("extra_infos", JSON.stringify(extraInfos));
+ },
+ _getExtraInfos: function() {
+ var members = {};
+ try {
+ members = JSON.parse(sessionStorage.extra_infos);
+ }
+ catch (err) {
+ }
+
+ return members;
+ }
+});
+
+ChannelStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+
+ switch(action.type) {
+
+ case ActionTypes.CLICK_CHANNEL:
+ ChannelStore.setCurrentId(action.id);
+ ChannelStore.setLastVisitedName(action.name);
+ ChannelStore.resetCounts(action.id);
+ ChannelStore.emitChange();
+ break;
+
+ case ActionTypes.RECIEVED_CHANNELS:
+ ChannelStore._storeChannels(action.channels);
+ ChannelStore._storeChannelMembers(action.members);
+ var currentId = ChannelStore.getCurrentId();
+ if (currentId) ChannelStore.resetCounts(currentId);
+ ChannelStore.emitChange();
+ break;
+
+ case ActionTypes.RECIEVED_MORE_CHANNELS:
+ ChannelStore._storeMoreChannels(action.channels);
+ ChannelStore.emitMoreChange();
+ break;
+
+ case ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO:
+ var extra_infos = ChannelStore._getExtraInfos();
+ extra_infos[action.extra_info.id] = action.extra_info;
+ ChannelStore._storeExtraInfos(extra_infos);
+ ChannelStore.emitExtraInfoChange();
+ break;
+
+ default:
+ }
+});
+
+module.exports = ChannelStore;
diff --git a/web/react/stores/error_store.jsx b/web/react/stores/error_store.jsx
new file mode 100644
index 000000000..82170034a
--- /dev/null
+++ b/web/react/stores/error_store.jsx
@@ -0,0 +1,59 @@
+// 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 assign = require('object-assign');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+var CHANGE_EVENT = 'change';
+
+var ErrorStore = assign({}, EventEmitter.prototype, {
+
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+ handledError: function() {
+ sessionStorage.removeItem("last_error");
+ },
+ getLastError: function() {
+ var error = null;
+ try {
+ error = JSON.parse(sessionStorage.last_error);
+ }
+ catch (err) {
+ }
+
+ return error;
+ },
+
+ _storeLastError: function(error) {
+ sessionStorage.setItem("last_error", JSON.stringify(error));
+ },
+});
+
+ErrorStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+ switch(action.type) {
+ case ActionTypes.RECIEVED_ERROR:
+ ErrorStore._storeLastError(action.err);
+ ErrorStore.emitChange();
+ break;
+
+ default:
+ }
+});
+
+module.exports = ErrorStore;
+
+
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
new file mode 100644
index 000000000..05479f444
--- /dev/null
+++ b/web/react/stores/post_store.jsx
@@ -0,0 +1,224 @@
+// 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 assign = require('object-assign');
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+var CHANGE_EVENT = 'change';
+var SEARCH_CHANGE_EVENT = 'search_change';
+var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
+var SELECTED_POST_CHANGE_EVENT = 'selected_post_change';
+var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
+var ADD_MENTION_EVENT = 'add_mention';
+
+var PostStore = assign({}, EventEmitter.prototype, {
+
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+
+ emitSearchChange: function() {
+ this.emit(SEARCH_CHANGE_EVENT);
+ },
+
+ addSearchChangeListener: function(callback) {
+ this.on(SEARCH_CHANGE_EVENT, callback);
+ },
+
+ removeSearchChangeListener: function(callback) {
+ this.removeListener(SEARCH_CHANGE_EVENT, callback);
+ },
+
+ emitSearchTermChange: function(doSearch, isMentionSearch) {
+ this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch);
+ },
+
+ addSearchTermChangeListener: function(callback) {
+ this.on(SEARCH_TERM_CHANGE_EVENT, callback);
+ },
+
+ removeSearchTermChangeListener: function(callback) {
+ this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback);
+ },
+
+ emitSelectedPostChange: function(from_search) {
+ this.emit(SELECTED_POST_CHANGE_EVENT, from_search);
+ },
+
+ addSelectedPostChangeListener: function(callback) {
+ this.on(SELECTED_POST_CHANGE_EVENT, callback);
+ },
+
+ removeSelectedPostChangeListener: function(callback) {
+ this.removeListener(SELECTED_POST_CHANGE_EVENT, callback);
+ },
+
+ emitMentionDataChange: function(id, mentionText, excludeList) {
+ this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText, excludeList);
+ },
+
+ addMentionDataChangeListener: function(callback) {
+ this.on(MENTION_DATA_CHANGE_EVENT, callback);
+ },
+
+ removeMentionDataChangeListener: function(callback) {
+ this.removeListener(MENTION_DATA_CHANGE_EVENT, callback);
+ },
+
+ emitAddMention: function(id, username) {
+ this.emit(ADD_MENTION_EVENT, id, username);
+ },
+
+ addAddMentionListener: function(callback) {
+ this.on(ADD_MENTION_EVENT, callback);
+ },
+
+ removeAddMentionListener: function(callback) {
+ this.removeListener(ADD_MENTION_EVENT, callback);
+ },
+
+ getCurrentPosts: function() {
+ var currentId = ChannelStore.getCurrentId();
+
+ if (currentId != null)
+ return this.getPosts(currentId);
+ else
+ return null;
+ },
+ storePosts: function(channelId, posts) {
+ this._storePosts(channelId, posts);
+ this.emitChange();
+ },
+ _storePosts: function(channelId, posts) {
+ sessionStorage.setItem("posts_" + channelId, JSON.stringify(posts));
+ },
+ getPosts: function(channelId) {
+ var posts = null;
+ try {
+ posts = JSON.parse(sessionStorage.getItem("posts_" + channelId));
+ }
+ catch (err) {
+ }
+
+ return posts;
+ },
+ storeSearchResults: function(results, is_mention_search) {
+ sessionStorage.setItem("search_results", JSON.stringify(results));
+ is_mention_search = is_mention_search ? true : false; // force to bool
+ sessionStorage.setItem("is_mention_search", JSON.stringify(is_mention_search));
+ },
+ getSearchResults: function() {
+ var results = null;
+ try {
+ results = JSON.parse(sessionStorage.getItem("search_results"));
+ }
+ catch (err) {
+ }
+
+ return results;
+ },
+ getIsMentionSearch: function() {
+ var result = false;
+ try {
+ result = JSON.parse(sessionStorage.getItem("is_mention_search"));
+ }
+ catch (err) {
+ }
+
+ return result;
+ },
+ storeSelectedPost: function(post_list) {
+ sessionStorage.setItem("select_post", JSON.stringify(post_list));
+ },
+ getSelectedPost: function() {
+ var post_list = null;
+ try {
+ post_list = JSON.parse(sessionStorage.getItem("select_post"));
+ }
+ catch (err) {
+ }
+
+ return post_list;
+ },
+ storeSearchTerm: function(term) {
+ sessionStorage.setItem("search_term", term);
+ },
+ getSearchTerm: function() {
+ return sessionStorage.getItem("search_term");
+ },
+ storeCurrentDraft: function(draft) {
+ var channel_id = ChannelStore.getCurrentId();
+ var user_id = UserStore.getCurrentId();
+ localStorage.setItem("draft_" + channel_id + "_" + user_id, JSON.stringify(draft));
+ },
+ getCurrentDraft: function() {
+ var channel_id = ChannelStore.getCurrentId();
+ var user_id = UserStore.getCurrentId();
+ return JSON.parse(localStorage.getItem("draft_" + channel_id + "_" + user_id));
+ },
+ storeDraft: function(channel_id, user_id, draft) {
+ localStorage.setItem("draft_" + channel_id + "_" + user_id, JSON.stringify(draft));
+ },
+ getDraft: function(channel_id, user_id) {
+ return JSON.parse(localStorage.getItem("draft_" + channel_id + "_" + user_id));
+ },
+ clearDraftUploads: function() {
+ for (key in localStorage) {
+ if (key.substring(0,6) === "draft_") {
+ var d = JSON.parse(localStorage.getItem(key));
+ if (d) {
+ d['uploadsInProgress'] = 0;
+ localStorage.setItem(key, JSON.stringify(d));
+ }
+ }
+ }
+ }
+});
+
+PostStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+
+ switch(action.type) {
+ case ActionTypes.RECIEVED_POSTS:
+ PostStore._storePosts(action.id, action.post_list);
+ PostStore.emitChange();
+ break;
+ case ActionTypes.RECIEVED_SEARCH:
+ PostStore.storeSearchResults(action.results, action.is_mention_search);
+ PostStore.emitSearchChange();
+ break;
+ case ActionTypes.RECIEVED_SEARCH_TERM:
+ PostStore.storeSearchTerm(action.term);
+ PostStore.emitSearchTermChange(action.do_search, action.is_mention_search);
+ break;
+ case ActionTypes.RECIEVED_POST_SELECTED:
+ PostStore.storeSelectedPost(action.post_list);
+ PostStore.emitSelectedPostChange(action.from_search);
+ break;
+ case ActionTypes.RECIEVED_MENTION_DATA:
+ PostStore.emitMentionDataChange(action.id, action.mention_text, action.exclude_list);
+ break;
+ case ActionTypes.RECIEVED_ADD_MENTION:
+ PostStore.emitAddMention(action.id, action.username);
+ break;
+
+ default:
+ }
+});
+
+module.exports = PostStore;
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
new file mode 100644
index 000000000..8ebb854c9
--- /dev/null
+++ b/web/react/stores/socket_store.jsx
@@ -0,0 +1,86 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var UserStore = require('./user_store.jsx')
+var EventEmitter = require('events').EventEmitter;
+var assign = require('object-assign');
+var client = require('../utils/client.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+var CHANGE_EVENT = 'change';
+
+var conn;
+
+var SocketStore = assign({}, EventEmitter.prototype, {
+ initialize: function(self) {
+ if (!UserStore.getCurrentId()) return;
+
+ if (!self) self = this;
+ self.setMaxListeners(0);
+
+ if (window["WebSocket"] && !conn) {
+ var protocol = window.location.protocol == "https:" ? "wss://" : "ws://";
+ var port = window.location.protocol == "https:" ? ":8443" : "";
+ var conn_url = protocol + location.host + port + "/api/v1/websocket";
+ console.log("connecting to " + conn_url);
+ conn = new WebSocket(conn_url);
+
+ conn.onclose = function(evt) {
+ console.log("websocket closed");
+ console.log(evt);
+ conn = null;
+ setTimeout(function(){self.initialize(self)}, 3000);
+ };
+
+ conn.onerror = function(evt) {
+ console.log("websocket error");
+ console.log(evt);
+ };
+
+ conn.onmessage = function(evt) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_MSG,
+ msg: JSON.parse(evt.data)
+ });
+ };
+ }
+ },
+ emitChange: function(msg) {
+ this.emit(CHANGE_EVENT, msg);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+ sendMessage: function (msg) {
+ if (conn && conn.readyState === WebSocket.OPEN) {
+ conn.send(JSON.stringify(msg));
+ } else if (!conn || conn.readyState === WebSocket.Closed) {
+ conn = null;
+ this.initialize();
+ }
+ }
+});
+
+SocketStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+
+ switch(action.type) {
+ case ActionTypes.RECIEVED_MSG:
+ SocketStore.emitChange(action.msg);
+ break;
+ default:
+ }
+});
+
+SocketStore.initialize();
+module.exports = SocketStore;
+
+
+
+
diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx
new file mode 100644
index 000000000..bbca92c84
--- /dev/null
+++ b/web/react/stores/user_store.jsx
@@ -0,0 +1,328 @@
+// 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 assign = require('object-assign');
+var client = require('../utils/client.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+var CHANGE_EVENT = 'change';
+var CHANGE_EVENT_SESSIONS = 'change_sessions';
+var CHANGE_EVENT_AUDITS = 'change_audits';
+var CHANGE_EVENT_TEAMS = 'change_teams';
+var CHANGE_EVENT_STATUSES = 'change_statuses';
+
+var UserStore = assign({}, EventEmitter.prototype, {
+
+ emitChange: function(userId) {
+ this.emit(CHANGE_EVENT, userId);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+ emitSessionsChange: function() {
+ this.emit(CHANGE_EVENT_SESSIONS);
+ },
+ addSessionsChangeListener: function(callback) {
+ this.on(CHANGE_EVENT_SESSIONS, callback);
+ },
+ removeSessionsChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT_SESSIONS, callback);
+ },
+ emitAuditsChange: function() {
+ this.emit(CHANGE_EVENT_AUDITS);
+ },
+ addAuditsChangeListener: function(callback) {
+ this.on(CHANGE_EVENT_AUDITS, callback);
+ },
+ removeAuditsChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT_AUDITS, callback);
+ },
+ emitTeamsChange: function() {
+ this.emit(CHANGE_EVENT_TEAMS);
+ },
+ addTeamsChangeListener: function(callback) {
+ this.on(CHANGE_EVENT_TEAMS, callback);
+ },
+ removeTeamsChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT_TEAMS, callback);
+ },
+ emitStatusesChange: function() {
+ this.emit(CHANGE_EVENT_STATUSES);
+ },
+ addStatusesChangeListener: function(callback) {
+ this.on(CHANGE_EVENT_STATUSES, callback);
+ },
+ removeStatusesChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT_STATUSES, callback);
+ },
+ setCurrentId: function(id) {
+ if (id == null)
+ sessionStorage.removeItem("current_user_id");
+ else
+ sessionStorage.setItem("current_user_id", id);
+ },
+ getCurrentId: function(skipFetch) {
+ var current_id = sessionStorage.current_user_id;
+
+ // this is a speical case to force fetch the
+ // current user if it's missing
+ // it's synchronous to block rendering
+ if (current_id == null && !skipFetch) {
+ var me = client.getMeSynchronous();
+ if (me != null) {
+ this.setCurrentUser(me);
+ current_id = me.id;
+ }
+ }
+
+ return current_id;
+ },
+ getCurrentUser: function(skipFetch) {
+ if (this.getCurrentId(skipFetch) == null) {
+ return null;
+ }
+
+ return this._getProfiles()[this.getCurrentId()];
+ },
+ setCurrentUser: function(user) {
+ this.saveProfile(user);
+ this.setCurrentId(user.id);
+ },
+ getLastDomain: function() {
+ return localStorage.last_domain;
+ },
+ setLastDomain: function(domain) {
+ localStorage.setItem("last_domain", domain);
+ },
+ getLastEmail: function() {
+ return localStorage.last_email;
+ },
+ setLastEmail: function(email) {
+ localStorage.setItem("last_email", email);
+ },
+ removeCurrentUser: function() {
+ this.setCurrentId(null);
+ },
+ hasProfile: function(userId) {
+ return this._getProfiles()[userId] != null;
+ },
+ getProfile: function(userId) {
+ return this._getProfiles()[userId];
+ },
+ getProfileByUsername: function(username) {
+ return this._getProfilesUsernameMap()[username];
+ },
+ getProfilesUsernameMap: function() {
+ return this._getProfilesUsernameMap();
+ },
+ getProfiles: function() {
+
+ return this._getProfiles();
+ },
+ getActiveOnlyProfiles: function() {
+ active = {};
+ current = this._getProfiles();
+
+ for (var key in current) {
+ if (current[key].delete_at == 0) {
+ active[key] = current[key];
+ }
+ }
+
+ return active;
+ },
+ saveProfile: function(profile) {
+ var ps = this._getProfiles();
+ ps[profile.id] = profile;
+ this._storeProfiles(ps);
+ },
+ _storeProfiles: function(profiles) {
+ sessionStorage.setItem("profiles", JSON.stringify(profiles));
+ var profileUsernameMap = {};
+ for (var id in profiles) {
+ profileUsernameMap[profiles[id].username] = profiles[id];
+ }
+ sessionStorage.setItem("profileUsernameMap", JSON.stringify(profileUsernameMap));
+ },
+ _getProfiles: function() {
+ var profiles = {};
+ try {
+ profiles = JSON.parse(sessionStorage.getItem("profiles"));
+
+ if (profiles == null) {
+ profiles = {};
+ }
+ }
+ catch (err) {
+ }
+
+ return profiles;
+ },
+ _getProfilesUsernameMap: function() {
+ var profileUsernameMap = {};
+ try {
+ profileUsernameMap = JSON.parse(sessionStorage.getItem("profileUsernameMap"));
+
+ if (profileUsernameMap == null) {
+ profileUsernameMap = {};
+ }
+ }
+ catch (err) {
+ }
+
+ return profileUsernameMap;
+ },
+ setSessions: function(sessions) {
+ sessionStorage.setItem("sessions", JSON.stringify(sessions));
+ },
+ getSessions: function() {
+ var sessions = [];
+ try {
+ sessions = JSON.parse(sessionStorage.getItem("sessions"));
+
+ if (sessions == null) {
+ sessions = [];
+ }
+ }
+ catch (err) {
+ }
+
+ return sessions;
+ },
+ setAudits: function(audits) {
+ sessionStorage.setItem("audits", JSON.stringify(audits));
+ },
+ getAudits: function() {
+ var audits = [];
+ try {
+ audits = JSON.parse(sessionStorage.getItem("audits"));
+
+ if (audits == null) {
+ audits = [];
+ }
+ }
+ catch (err) {
+ }
+
+ return audits;
+ },
+ setTeams: function(teams) {
+ sessionStorage.setItem("teams", JSON.stringify(teams));
+ },
+ getTeams: function() {
+ var teams = [];
+ try {
+ teams = JSON.parse(sessionStorage.getItem("teams"));
+
+ if (teams == null) {
+ teams = [];
+ }
+ }
+ catch (err) {
+ }
+
+ return teams;
+ },
+ getCurrentMentionKeys: function() {
+ var user = this.getCurrentUser();
+ if (user.notify_props && user.notify_props.mention_keys) {
+ var keys = user.notify_props.mention_keys.split(',');
+
+ if (user.full_name.length > 0 && user.notify_props.first_name === "true") {
+ var first = user.full_name.split(' ')[0];
+ if (first.length > 0) keys.push(first);
+ }
+
+ return keys;
+ } else {
+ return [];
+ }
+ },
+ getLastVersion: function() {
+ return sessionStorage.last_version;
+ },
+ setLastVersion: function(version) {
+ sessionStorage.setItem("last_version", version);
+ },
+ setStatuses: function(statuses) {
+ this._setStatuses(statuses);
+ this.emitStatusesChange();
+ },
+ _setStatuses: function(statuses) {
+ sessionStorage.setItem("statuses", JSON.stringify(statuses));
+ },
+ setStatus: function(user_id, status) {
+ var statuses = this.getStatuses();
+ statuses[user_id] = status;
+ this._setStatuses(statuses);
+ this.emitStatusesChange();
+ },
+ getStatuses: function() {
+ var statuses = {};
+ try {
+ statuses = JSON.parse(sessionStorage.getItem("statuses"));
+
+ if (statuses == null) {
+ statuses = {};
+ }
+ }
+ catch (err) {
+ }
+
+ return statuses;
+ },
+ getStatus: function(id) {
+ return this.getStatuses()[id];
+ }
+});
+
+UserStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+
+ switch(action.type) {
+ case ActionTypes.RECIEVED_PROFILES:
+ for(var id in action.profiles) {
+ // profiles can have incomplete data, so don't overwrite current user
+ if (id === UserStore.getCurrentId()) continue;
+ var profile = action.profiles[id];
+ UserStore.saveProfile(profile);
+ UserStore.emitChange(profile.id);
+ }
+ break;
+ case ActionTypes.RECIEVED_ME:
+ UserStore.setCurrentUser(action.me);
+ UserStore.emitChange(action.me.id);
+ break;
+ case ActionTypes.RECIEVED_SESSIONS:
+ UserStore.setSessions(action.sessions);
+ UserStore.emitSessionsChange();
+ break;
+ case ActionTypes.RECIEVED_AUDITS:
+ UserStore.setAudits(action.audits);
+ UserStore.emitAuditsChange();
+ break;
+ case ActionTypes.RECIEVED_TEAMS:
+ UserStore.setTeams(action.teams);
+ UserStore.emitTeamsChange();
+ break;
+ case ActionTypes.RECIEVED_STATUSES:
+ UserStore._setStatuses(action.statuses);
+ UserStore.emitStatusesChange();
+ break;
+
+ default:
+ }
+});
+
+UserStore.setMaxListeners(0);
+global.window.UserStore = UserStore;
+module.exports = UserStore;
+
+
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
new file mode 100644
index 000000000..bb7ca458f
--- /dev/null
+++ b/web/react/utils/async_client.jsx
@@ -0,0 +1,357 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var client = require('./client.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var PostStore = require('../stores/post_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var utils = require('./utils.jsx');
+
+var Constants = require('./constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+// Used to track in progress async calls
+var callTracker = {};
+
+var dispatchError = function(err, method) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_ERROR,
+ err: err,
+ method: method
+ });
+};
+
+var isCallInProgress = function(callName) {
+ if (!(callName in callTracker)) return false;
+
+ if (callTracker[callName] === 0) return false;
+
+ if (utils.getTimestamp() - callTracker[callName] > 5000) {
+ console.log("AsyncClient call " + callName + " expired after more than 5 seconds");
+ return false;
+ }
+
+ return true;
+};
+
+module.exports.dispatchError = dispatchError;
+
+module.exports.getChannels = function(force, updateLastViewed, checkVersion) {
+ if (isCallInProgress("getChannels")) return;
+
+ if (ChannelStore.getAll().length == 0 || force) {
+ callTracker["getChannels"] = utils.getTimestamp();
+ client.getChannels(
+ function(data, textStatus, xhr) {
+ callTracker["getChannels"] = 0;
+
+ if (updateLastViewed && ChannelStore.getCurrentId() != null) {
+ module.exports.updateLastViewedAt();
+ }
+
+ if (checkVersion) {
+ var serverVersion = xhr.getResponseHeader("X-Version-ID");
+
+ if (UserStore.getLastVersion() == undefined) {
+ UserStore.setLastVersion(serverVersion);
+ }
+
+ if (serverVersion != UserStore.getLastVersion()) {
+ UserStore.setLastVersion(serverVersion);
+ window.location.href = window.location.href;
+ console.log("Detected version update refreshing the page");
+ }
+ }
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_CHANNELS,
+ channels: data.channels,
+ members: data.members
+ });
+
+ },
+ function(err) {
+ callTracker["getChannels"] = 0;
+ dispatchError(err, "getChannels");
+ }
+ );
+ }
+}
+
+module.exports.updateLastViewedAt = function() {
+ if (isCallInProgress("updateLastViewed")) return;
+
+ if (ChannelStore.getCurrentId() == null) return;
+
+ callTracker["updateLastViewed"] = utils.getTimestamp();
+ client.updateLastViewedAt(
+ ChannelStore.getCurrentId(),
+ function(data) {
+ callTracker["updateLastViewed"] = 0;
+ },
+ function(err) {
+ callTracker["updateLastViewed"] = 0;
+ dispatchError(err, "updateLastViewedAt");
+ }
+ );
+}
+
+module.exports.getMoreChannels = function(force) {
+ if (isCallInProgress("getMoreChannels")) return;
+
+ if (ChannelStore.getMoreAll().length == 0 || force) {
+
+ callTracker["getMoreChannels"] = utils.getTimestamp();
+ client.getMoreChannels(
+ function(data, textStatus, xhr) {
+ callTracker["getMoreChannels"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_MORE_CHANNELS,
+ channels: data.channels,
+ members: data.members
+ });
+ },
+ function(err) {
+ callTracker["getMoreChannels"] = 0;
+ dispatchError(err, "getMoreChannels");
+ }
+ );
+ }
+}
+
+module.exports.getChannelExtraInfo = function(force) {
+ var channelId = ChannelStore.getCurrentId();
+
+ if (channelId != null) {
+ if (isCallInProgress("getChannelExtraInfo_"+channelId)) return;
+ var minMembers = ChannelStore.getCurrent() && ChannelStore.getCurrent().type === 'D' ? 1 : 0;
+
+ if (ChannelStore.getCurrentExtraInfo().members.length <= minMembers || force) {
+ callTracker["getChannelExtraInfo_"+channelId] = utils.getTimestamp();
+ client.getChannelExtraInfo(
+ channelId,
+ function(data, textStatus, xhr) {
+ callTracker["getChannelExtraInfo_"+channelId] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO,
+ extra_info: data
+ });
+ },
+ function(err) {
+ callTracker["getChannelExtraInfo_"+channelId] = 0;
+ dispatchError(err, "getChannelExtraInfo");
+ }
+ );
+ }
+ }
+}
+
+module.exports.getProfiles = function() {
+ if (isCallInProgress("getProfiles")) return;
+
+ callTracker["getProfiles"] = utils.getTimestamp();
+ client.getProfiles(
+ function(data, textStatus, xhr) {
+ callTracker["getProfiles"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_PROFILES,
+ profiles: data
+ });
+ },
+ function(err) {
+ callTracker["getProfiles"] = 0;
+ dispatchError(err, "getProfiles");
+ }
+ );
+}
+
+module.exports.getSessions = function() {
+ if (isCallInProgress("getSessions")) return;
+
+ callTracker["getSessions"] = utils.getTimestamp();
+ client.getSessions(
+ UserStore.getCurrentId(),
+ function(data, textStatus, xhr) {
+ callTracker["getSessions"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SESSIONS,
+ sessions: data
+ });
+ },
+ function(err) {
+ callTracker["getSessions"] = 0;
+ dispatchError(err, "getSessions");
+ }
+ );
+}
+
+module.exports.getAudits = function() {
+ if (isCallInProgress("getAudits")) return;
+
+ callTracker["getAudits"] = utils.getTimestamp();
+ client.getAudits(
+ UserStore.getCurrentId(),
+ function(data, textStatus, xhr) {
+ callTracker["getAudits"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_AUDITS,
+ audits: data
+ });
+ },
+ function(err) {
+ callTracker["getAudits"] = 0;
+ dispatchError(err, "getAudits");
+ }
+ );
+}
+
+module.exports.findTeams = function(email) {
+ if (isCallInProgress("findTeams_"+email)) return;
+
+ var user = UserStore.getCurrentUser();
+ if (user) {
+ callTracker["findTeams_"+email] = utils.getTimestamp();
+ client.findTeams(
+ user.email,
+ function(data, textStatus, xhr) {
+ callTracker["findTeams_"+email] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_TEAMS,
+ teams: data
+ });
+ },
+ function(err) {
+ callTracker["findTeams_"+email] = 0;
+ dispatchError(err, "findTeams");
+ }
+ );
+ }
+}
+
+module.exports.search = function(terms) {
+ if (isCallInProgress("search_"+String(terms))) return;
+
+ callTracker["search_"+String(terms)] = utils.getTimestamp();
+ client.search(
+ terms,
+ function(data, textStatus, xhr) {
+ callTracker["search_"+String(terms)] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: data
+ });
+ },
+ function(err) {
+ callTracker["search_"+String(terms)] = 0;
+ dispatchError(err, "search");
+ }
+ );
+}
+
+module.exports.getPosts = function(force, id) {
+ if (PostStore.getCurrentPosts() == null || force) {
+ var channelId = id ? id : ChannelStore.getCurrentId();
+
+ if (isCallInProgress("getPosts_"+channelId)) return;
+
+ var post_list = PostStore.getCurrentPosts();
+ // if we already have more than POST_CHUNK_SIZE posts,
+ // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE,
+ // with a max at 180
+ var numPosts = post_list && post_list.order.length > 0 ? Math.min(180, Constants.POST_CHUNK_SIZE * Math.ceil(post_list.order.length / Constants.POST_CHUNK_SIZE)) : Constants.POST_CHUNK_SIZE;
+
+ if (channelId != null) {
+ callTracker["getPosts_"+channelId] = utils.getTimestamp();
+ client.getPosts(
+ channelId,
+ 0,
+ numPosts,
+ function(data, textStatus, xhr) {
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POSTS,
+ id: channelId,
+ post_list: data
+ });
+
+ module.exports.getProfiles();
+ },
+ function(err) {
+ dispatchError(err, "getPosts");
+ },
+ function() {
+ callTracker["getPosts_"+channelId] = 0;
+ }
+ );
+ }
+ }
+}
+
+module.exports.getMe = function() {
+ if (isCallInProgress("getMe")) return;
+
+ callTracker["getMe"] = utils.getTimestamp();
+ client.getMeSynchronous(
+ function(data, textStatus, xhr) {
+ callTracker["getMe"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_ME,
+ me: data
+ });
+ },
+ function(err) {
+ callTracker["getMe"] = 0;
+ dispatchError(err, "getMe");
+ }
+ );
+}
+
+module.exports.getStatuses = function() {
+ if (isCallInProgress("getStatuses")) return;
+
+ callTracker["getStatuses"] = utils.getTimestamp();
+ client.getStatuses(
+ function(data, textStatus, xhr) {
+ callTracker["getStatuses"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_STATUSES,
+ statuses: data
+ });
+ },
+ function(err) {
+ callTracker["getStatuses"] = 0;
+ dispatchError(err, "getStatuses");
+ }
+ );
+}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
new file mode 100644
index 000000000..b83ee22e7
--- /dev/null
+++ b/web/react/utils/client.jsx
@@ -0,0 +1,813 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+
+module.exports.track = function(category, action, label, prop, val) {
+ global.window.snowplow('trackStructEvent', category, action, label, prop, val);
+ if (global.window.analytics != null) global.window.analytics.track(action, {category: category, label: label, property: prop, value: val});
+};
+
+module.exports.trackPage = function() {
+ global.window.snowplow('trackPageView');
+ if (global.window.analytics != null) global.window.analytics.page();
+};
+
+function handleError(method_name, xhr, status, err) {
+ var _LTracker = global.window._LTracker || [];
+
+ var e = null;
+ try {
+ e = JSON.parse(xhr.responseText);
+ }
+ catch(parse_error) {
+ }
+
+ var msg = "";
+
+ if (e) {
+ msg = "error in " + method_name + " msg=" + e.message + " detail=" + e.detailed_error + " rid=" + e.request_id;
+ }
+ else {
+ msg = "error in " + method_name + " status=" + status + " statusCode=" + xhr.status + " err=" + err;
+
+ if (xhr.status === 0)
+ e = { message: "There appears to be a problem with your internet connection" };
+ else
+ e = { message: "We received an unexpected status code from the server (" + xhr.status + ")"};
+ }
+
+ console.error(msg)
+ console.error(e);
+ _LTracker.push(msg);
+
+ module.exports.track('api', 'api_weberror', method_name, 'message', msg);
+
+ if (xhr.status == 401) {
+ window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname+window.location.search);
+ }
+
+ return e;
+}
+
+module.exports.createTeamFromSignup = function(team_signup, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/create_from_signup",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(team_signup),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("createTeamFromSignup", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.createUser = function(user, data, email_hash, success, error) {
+ $.ajax({
+ url: "/api/v1/users/create?d=" + encodeURIComponent(data) + "&h=" + encodeURIComponent(email_hash),
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(user),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("createUser", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_create', user.team_id, 'email', user.email);
+};
+
+module.exports.updateUser = function(user, success, error) {
+ $.ajax({
+ url: "/api/v1/users/update",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(user),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateUser", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_update');
+};
+
+module.exports.updatePassword = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/users/newpassword",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("newPassword", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_newpassword');
+};
+
+module.exports.updateUserNotifyProps = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/users/update_notify",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateUserNotifyProps", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.updateRoles = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/users/update_roles",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateRoles", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_update_roles');
+};
+
+module.exports.updateActive = function(userId, active, success, error) {
+ var data = {};
+ data["user_id"] = userId;
+ data["active"] = "" + active;
+
+ $.ajax({
+ url: "/api/v1/users/update_active",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateActive", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_update_roles');
+};
+
+module.exports.sendPasswordReset = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/users/send_password_reset",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("sendPasswordReset", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_send_password_reset');
+};
+
+module.exports.resetPassword = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/users/reset_password",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("resetPassword", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_users_reset_password');
+};
+
+module.exports.logout = function() {
+ module.exports.track('api', 'api_users_logout');
+ sessionStorage.clear();
+ window.location.href = "/logout";
+};
+
+module.exports.loginByEmail = function(domain, email, password, success, error) {
+ $.ajax({
+ url: "/api/v1/users/login",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({domain: domain, email: email, password: password}),
+ success: function(data, textStatus, xhr) {
+ module.exports.track('api', 'api_users_login_success', data.team_id, 'email', data.email);
+ success(data, textStatus, xhr);
+ },
+ error: function(xhr, status, err) {
+ module.exports.track('api', 'api_users_login_fail', window.getSubDomain(), 'email', email);
+
+ e = handleError("loginByEmail", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.revokeSession = function(altId, success, error) {
+ $.ajax({
+ url: "/api/v1/users/revoke_session",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({id: altId}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("revokeSession", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getSessions = function(userId, success, error) {
+ $.ajax({
+ url: "/api/v1/users/"+userId+"/sessions",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'GET',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getSessions", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getAudits = function(userId, success, error) {
+ $.ajax({
+ url: "/api/v1/users/"+userId+"/audits",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'GET',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getAudits", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getMeSynchronous = function(success, error) {
+
+ var current_user = null;
+
+ $.ajax({
+ async: false,
+ url: "/api/v1/users/me",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'GET',
+ success: function(data, textStatus, xhr) {
+ current_user = data;
+ if (success) success(data, textStatus, xhr);
+ },
+ error: function(xhr, status, err) {
+ if (error) {
+ e = handleError("getMeSynchronous", xhr, status, err);
+ error(e);
+ };
+ }
+ });
+
+ return current_user;
+};
+
+module.exports.inviteMembers = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/invite_members",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("inviteMembers", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_teams_invite_members');
+};
+
+module.exports.updateTeamName = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/update_name",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateTeamName", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_teams_update_name');
+};
+
+module.exports.signupTeam = function(email, name, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/signup",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({email: email, name: name}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("singupTeam", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_teams_signup');
+};
+
+module.exports.createTeam = function(team, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/create",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(team),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("createTeam", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.findTeamByDomain = function(domain, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/find_team_by_domain",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({domain: domain}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("findTeamByDomain", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.findTeamsSendEmail = function(email, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/email_teams",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({email: email}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("findTeamsSendEmail", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_teams_email_teams');
+};
+
+module.exports.findTeams = function(email, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/find_teams",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({email: email}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("findTeams", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.createChannel = function(channel, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/create",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(channel),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("createChannel", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_create', channel.type, 'name', channel.name);
+};
+
+module.exports.updateChannel = function(channel, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/update",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(channel),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateChannel", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_update');
+};
+
+module.exports.updateChannelDesc = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/update_desc",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateChannelDesc", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_desc');
+};
+
+module.exports.updateNotifyLevel = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/update_notify_level",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateNotifyLevel", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.joinChannel = function(id, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/join",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("joinChannel", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_join');
+};
+
+module.exports.leaveChannel = function(id, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/leave",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("leaveChannel", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_leave');
+};
+
+module.exports.deleteChannel = function(id, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/delete",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("deleteChannel", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_delete');
+};
+
+module.exports.updateLastViewedAt = function(channelId, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + channelId + "/update_last_viewed_at",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateLastViewedAt", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getChannels = function(success, error) {
+ $.ajax({
+ url: "/api/v1/channels/",
+ dataType: 'json',
+ type: 'GET',
+ success: success,
+ ifModified: true,
+ error: function(xhr, status, err) {
+ e = handleError("getChannels", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getMoreChannels = function(success, error) {
+ $.ajax({
+ url: "/api/v1/channels/more",
+ dataType: 'json',
+ type: 'GET',
+ success: success,
+ ifModified: true,
+ error: function(xhr, status, err) {
+ e = handleError("getMoreChannels", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getChannelExtraInfo = function(id, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/extra_info",
+ dataType: 'json',
+ type: 'GET',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getChannelExtraInfo", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.executeCommand = function(channelId, command, suggest, success, error) {
+ $.ajax({
+ url: "/api/v1/command",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({channelId: channelId, command: command, suggest: "" + suggest}),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("executeCommand", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getPosts = function(channelId, offset, limit, success, error, complete) {
+ $.ajax({
+ url: "/api/v1/channels/" + channelId + "/posts/" + offset + "/" + limit,
+ dataType: 'json',
+ type: 'GET',
+ ifModified: true,
+ success: success,
+ error: function(xhr, status, err) {
+ try {
+ e = handleError("getPosts", xhr, status, err);
+ error(e);
+ } catch(er) {
+ console.error(er);
+ }
+ },
+ complete: complete
+ });
+};
+
+module.exports.getPost = function(channelId, postId, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + channelId + "/post/" + postId,
+ dataType: 'json',
+ type: 'GET',
+ ifModified: false,
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getPost", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.search = function(terms, success, error) {
+ $.ajax({
+ url: "/api/v1/posts/search",
+ dataType: 'json',
+ type: 'GET',
+ data: {"terms": terms},
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("search", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_posts_search');
+};
+
+module.exports.deletePost = function(channelId, id, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + channelId + "/post/" + id + "/delete",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("deletePost", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_posts_delete');
+};
+
+module.exports.createPost = function(post, channel, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/"+ post.channel_id + "/create",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(post),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("createPost", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_posts_create', channel.name, 'length', post.message.length);
+
+ // global.window.analytics.track('api_posts_create', {
+ // category: 'api',
+ // channel_name: channel.name,
+ // channel_type: channel.type,
+ // length: post.message.length,
+ // files: (post.filenames || []).length,
+ // mentions: (post.message.match("/<mention>/g") || []).length
+ // });
+};
+
+module.exports.updatePost = function(post, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/"+ post.channel_id + "/update",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(post),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updatePost", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_posts_update');
+};
+
+module.exports.addChannelMember = function(id, data, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/add",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("addChannelMember", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_add_member');
+};
+
+module.exports.removeChannelMember = function(id, data, success, error) {
+ $.ajax({
+ url: "/api/v1/channels/" + id + "/remove",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("removeChannelMember", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_channels_remove_member');
+};
+
+module.exports.getProfiles = function(success, error) {
+ $.ajax({
+ url: "/api/v1/users/profiles",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'GET',
+ success: success,
+ ifModified: true,
+ error: function(xhr, status, err) {
+ e = handleError("getProfiles", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.uploadFile = function(formData, success, error) {
+ $.ajax({
+ url: "/api/v1/files/upload",
+ type: 'POST',
+ data: formData,
+ cache: false,
+ contentType: false,
+ processData: false,
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("uploadFile", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_files_upload');
+};
+
+module.exports.getPublicLink = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/files/get_public_link",
+ dataType: 'json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getPublicLink", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.uploadProfileImage = function(imageData, success, error) {
+ $.ajax({
+ url: "/api/v1/users/newimage",
+ type: 'POST',
+ data: imageData,
+ cache: false,
+ contentType: false,
+ processData: false,
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("uploadProfileImage", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.getStatuses = function(success, error) {
+ $.ajax({
+ url: "/api/v1/users/status",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'GET',
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("getStatuses", xhr, status, err);
+ error(e);
+ }
+ });
+};
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
new file mode 100644
index 000000000..0a3b1db3d
--- /dev/null
+++ b/web/react/utils/constants.jsx
@@ -0,0 +1,78 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var keyMirror = require('keymirror');
+
+module.exports = {
+ ActionTypes: keyMirror({
+ RECIEVED_ERROR: null,
+
+ CLICK_CHANNEL: null,
+ CREATE_CHANNEL: null,
+ RECIEVED_CHANNELS: null,
+ RECIEVED_MORE_CHANNELS: null,
+ RECIEVED_CHANNEL_EXTRA_INFO: null,
+
+ RECIEVED_POSTS: null,
+ RECIEVED_SEARCH: null,
+ RECIEVED_POST_SELECTED: null,
+ RECIEVED_MENTION_DATA: null,
+ RECIEVED_ADD_MENTION: null,
+
+ RECIEVED_PROFILES: null,
+ RECIEVED_ME: null,
+ RECIEVED_SESSIONS: null,
+ RECIEVED_AUDITS: null,
+ RECIEVED_TEAMS: null,
+ RECIEVED_STATUSES: null,
+
+ RECIEVED_MSG: null,
+ }),
+
+ PayloadSources: keyMirror({
+ SERVER_ACTION: null,
+ VIEW_ACTION: null
+ }),
+ CHARACTER_LIMIT: 4000,
+ IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png'],
+ AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac'],
+ VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'],
+ SPREADSHEET_TYPES: ['ppt', 'pptx', 'csv'],
+ EXCEL_TYPES: ['xlsx'],
+ WORD_TYPES: ['doc', 'docx'],
+ CODE_TYPES: ['css', 'html', 'js', 'php', 'rb'],
+ PDF_TYPES: ['pdf'],
+ PATCH_TYPES: ['patch'],
+ ICON_FROM_TYPE: {'audio': 'audio', 'video': 'video', 'spreadsheet': 'ppt', 'pdf': 'pdf', 'code': 'code' , 'word': 'word' , 'excel': 'excel' , 'patch': 'patch', 'other': 'generic'},
+ MAX_DISPLAY_FILES: 5,
+ MAX_FILE_SIZE: 50000000, // 50 MB
+ DEFAULT_CHANNEL: 'town-square',
+ POST_CHUNK_SIZE: 60,
+ RESERVED_DOMAINS: [
+ "www",
+ "web",
+ "admin",
+ "support",
+ "notify",
+ "test",
+ "demo",
+ "mail",
+ "team",
+ "channel",
+ "internal",
+ "localhost",
+ "stag",
+ "post",
+ "cluster",
+ "api",
+ ],
+ RESERVED_USERNAMES: [
+ "valet",
+ "all",
+ "channel",
+ ],
+ MONTHS: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
+ MAX_DMS: 10,
+ ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>",
+ OFFLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path fill='#cccccc' d='M6.002,7.143C5.645,7.363,5.167,7.52,4.502,7.52c-2.493,0-2.5-2.02-2.5-2.02S1.029,5.607,0.775,6.004C0.41,6.577,0.15,7.716,0.049,8.545c-0.025,0.145-0.057,0.537-0.05,0.598c0.162,1.295,2.237,2.321,4.375,2.357c0.043,0.001,0.085,0.001,0.127,0.001c0.043,0,0.084,0,0.127-0.001c1.879-0.023,3.793-0.879,4.263-2h-2.89L6.002,7.143L6.002,7.143z M4.501,5.488c1.372,0,2.483-1.117,2.483-2.494c0-1.378-1.111-2.495-2.483-2.495c-1.371,0-2.481,1.117-2.481,2.495C2.02,4.371,3.13,5.488,4.501,5.488z M7.002,6.5v2h5v-2H7.002z'/></g></g></svg>"
+};
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
new file mode 100644
index 000000000..72ed48faf
--- /dev/null
+++ b/web/react/utils/utils.jsx
@@ -0,0 +1,732 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+var AsyncClient = require('./async_client.jsx');
+var client = require('./client.jsx');
+var LinkifyIt = require('linkify-it');
+
+module.exports.isEmail = function(email) {
+ var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/;
+ return regex.test(email);
+};
+
+module.exports.cleanUpUrlable = function(input) {
+ var cleaned = input.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-');
+ cleaned = cleaned.replace(/^\-+/, '');
+ cleaned = cleaned.replace(/\-+$/, '');
+ return cleaned;
+};
+
+
+
+module.exports.isTestDomain = function() {
+
+ if ((/^localhost/).test(window.location.hostname))
+ return true;
+
+ if ((/^test/).test(window.location.hostname))
+ return true;
+
+ if ((/^127.0./).test(window.location.hostname))
+ return true;
+
+ if ((/^192.168./).test(window.location.hostname))
+ return true;
+
+ if ((/^10./).test(window.location.hostname))
+ return true;
+
+ if ((/^176./).test(window.location.hostname))
+ return true;
+
+ return false;
+};
+
+var getSubDomain = function() {
+
+ if (module.exports.isTestDomain())
+ return "";
+
+ if ((/^www/).test(window.location.hostname))
+ return "";
+
+ if ((/^beta/).test(window.location.hostname))
+ return "";
+
+ if ((/^ci/).test(window.location.hostname))
+ return "";
+
+ var parts = window.location.hostname.split(".");
+
+ if (parts.length != 3)
+ return "";
+
+ return parts[0];
+}
+
+global.window.getSubDomain = getSubDomain;
+module.exports.getSubDomain = getSubDomain;
+
+module.exports.getDomainWithOutSub = function() {
+
+ var parts = window.location.host.split(".");
+
+ if (parts.length == 1)
+ return "localhost:8065";
+
+ return parts[1] + "." + parts[2];
+}
+
+module.exports.getCookie = function(name) {
+ var value = "; " + document.cookie;
+ var parts = value.split("; " + name + "=");
+ if (parts.length == 2) return parts.pop().split(";").shift();
+}
+
+module.exports.notifyMe = function(title, body, channel) {
+ if ("Notification" in window && Notification.permission !== 'denied') {
+ Notification.requestPermission(function (permission) {
+ if (Notification.permission !== permission) {
+ Notification.permission = permission;
+ }
+
+ if (permission === "granted") {
+ var notification = new Notification(title,
+ { body: body, tag: body, icon: '/static/images/icon50x50.gif' }
+ );
+ notification.onclick = function() {
+ window.focus();
+ if (channel) {
+ module.exports.switchChannel(channel);
+ } else {
+ window.location.href = "/channels/town-square";
+ }
+ };
+ setTimeout(function(){
+ notification.close();
+ }, 5000);
+ }
+ });
+ }
+}
+
+module.exports.ding = function() {
+ var audio = new Audio('/static/images/ding.mp3');
+ audio.play();
+}
+
+module.exports.getUrlParameter = function(sParam) {
+ var sPageURL = window.location.search.substring(1);
+ var sURLVariables = sPageURL.split('&');
+ for (var i = 0; i < sURLVariables.length; i++)
+ {
+ var sParameterName = sURLVariables[i].split('=');
+ if (sParameterName[0] == sParam)
+ {
+ return sParameterName[1];
+ }
+ }
+ return null;
+}
+
+module.exports.getDateForUnixTicks = function(ticks) {
+ return new Date(ticks)
+}
+
+module.exports.displayDate = function(ticks) {
+ var d = new Date(ticks);
+ var m_names = new Array("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December");
+
+ return m_names[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
+}
+
+module.exports.displayTime = function(ticks) {
+ var d = new Date(ticks);
+ var hours = d.getHours();
+ var minutes = d.getMinutes();
+ var ampm = hours >= 12 ? "PM" : "AM";
+ hours = hours % 12;
+ hours = hours ? hours : "12"
+ minutes = minutes > 9 ? minutes : '0'+minutes
+ return hours + ":" + minutes + " " + ampm
+}
+
+module.exports.displayDateTime = function(ticks) {
+ var seconds = Math.floor((Date.now() - ticks) / 1000)
+
+ interval = Math.floor(seconds / 3600);
+
+ if (interval > 24) {
+ return this.displayTime(ticks)
+ }
+
+ if (interval > 1) {
+ return interval + " hours ago";
+ }
+
+ if (interval == 1) {
+ return interval + " hour ago";
+ }
+
+ interval = Math.floor(seconds / 60);
+ if (interval > 1) {
+ return interval + " minutes ago";
+ }
+
+ return "1 minute ago";
+
+}
+
+// returns Unix timestamp in milliseconds
+module.exports.getTimestamp = function() {
+ return Date.now();
+}
+
+module.exports.extractLinks = function(text) {
+ var repRegex = new RegExp("<br>", "g");
+ var linkMatcher = new LinkifyIt();
+ var matches = linkMatcher.match(text.replace(repRegex, "\n"));
+
+ if (!matches) return { "links": null, "text": text };
+
+ var links = []
+ for (var i = 0; i < matches.length; i++) {
+ links.push(matches[i].url)
+ }
+
+ return { "links": links, "text": text };
+}
+
+module.exports.escapeRegExp = function(string) {
+ return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
+}
+
+module.exports.getEmbed = function(link) {
+
+ var ytRegex = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|watch\?(?:[a-zA-Z-_]+=[a-zA-Z0-9-_]+&)+v=)([^#\&\?]*).*/;
+
+ var match = link.trim().match(ytRegex);
+ if (match && match[1].length==11){
+ return getYoutubeEmbed(link);
+ }
+
+ // Generl embed feature turned off for now
+ return;
+
+ var id = parseInt((Math.random() * 1000000) + 1);
+
+ $.ajax({
+ type: 'GET',
+ url: "https://query.yahooapis.com/v1/public/yql",
+ data: {
+ q: "select * from html where url=\""+link+"\" and xpath='html/head'",
+ format: "json"
+ },
+ async: true
+ }).done(function(data) {
+ if(!data.query.results) {
+ return;
+ }
+
+ var headerData = data.query.results.head;
+
+ var description = ""
+ for(var i = 0; i < headerData.meta.length; i++) {
+ if(headerData.meta[i].name && (headerData.meta[i].name === "description" || headerData.meta[i].name === "Description")){
+ description = headerData.meta[i].content;
+ break;
+ }
+ }
+
+ $('.embed-title.'+id).html(headerData.title);
+ $('.embed-description.'+id).html(description);
+ })
+
+ return (
+ <div className="post-comment">
+ <div className={"web-embed-data"}>
+ <p className={"embed-title " + id} />
+ <p className={"embed-description " + id} />
+ <p className={"embed-link " + id}>{link}</p>
+ </div>
+ </div>
+ );
+}
+
+var getYoutubeEmbed = function(link) {
+ var regex = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|watch\?(?:[a-zA-Z-_]+=[a-zA-Z0-9-_]+&)+v=)([^#\&\?]*).*/;
+
+ var youtubeId = link.trim().match(regex)[1];
+
+ var onclick = function(e) {
+ var div = $(e.target).closest('.video-thumbnail__container')[0];
+ var iframe = document.createElement("iframe");
+ iframe.setAttribute("src",
+ "https://www.youtube.com/embed/" + div.id
+ + "?autoplay=1&autohide=1&border=0&wmode=opaque&enablejsapi=1");
+ iframe.setAttribute("width", "480px");
+ iframe.setAttribute("height", "360px");
+ iframe.setAttribute("type", "text/html");
+ iframe.setAttribute("frameborder", "0");
+
+ div.parentNode.replaceChild(iframe, div);
+ };
+
+ var success = function(data) {
+ $('.video-uploader.'+youtubeId).html(data.data.uploader);
+ $('.video-title.'+youtubeId).find('a').html(data.data.title);
+ $(".post-list-holder-by-time").scrollTop($(".post-list-holder-by-time")[0].scrollHeight);
+ $(".post-list-holder-by-time").perfectScrollbar('update');
+ };
+
+ $.ajax({
+ async: true,
+ url: 'https://gdata.youtube.com/feeds/api/videos/'+youtubeId+'?v=2&alt=jsonc',
+ type: 'GET',
+ success: success
+ });
+
+ return (
+ <div className="post-comment">
+ <h4 className="video-type">YouTube</h4>
+ <h4 className={"video-uploader "+youtubeId}></h4>
+ <h4 className={"video-title "+youtubeId}><a href={link}></a></h4>
+ <div className="video-div embed-responsive-item" id={youtubeId} onClick={onclick}>
+ <div className="embed-responsive embed-responsive-4by3 video-div__placeholder">
+ <div id={youtubeId} className="video-thumbnail__container">
+ <img className="video-thumbnail" src={"https://i.ytimg.com/vi/" + youtubeId + "/hqdefault.jpg"}/>
+ <div className="block">
+ <span className="play-button"><span></span></span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+
+}
+
+module.exports.areStatesEqual = function(state1, state2) {
+ return JSON.stringify(state1) === JSON.stringify(state2);
+}
+
+module.exports.replaceHtmlEntities = function(text) {
+ var tagsToReplace = {
+ '&amp;': '&',
+ '&lt;': '<',
+ '&gt;': '>'
+ };
+ for (var tag in tagsToReplace) {
+ var regex = new RegExp(tag, "g");
+ text = text.replace(regex, tagsToReplace[tag]);
+ }
+ return text;
+}
+
+module.exports.insertHtmlEntities = function(text) {
+ var tagsToReplace = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;'
+ };
+ for (var tag in tagsToReplace) {
+ var regex = new RegExp(tag, "g");
+ text = text.replace(regex, tagsToReplace[tag]);
+ }
+ return text;
+}
+
+module.exports.searchForTerm = function(term) {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH_TERM,
+ term: term,
+ do_search: true
+ });
+}
+
+var oldExplicitMentionRegex = /(?:<mention>)([\s\S]*?)(?:<\/mention>)/g;
+var puncStartRegex = /^((?![@#])\W)+/g;
+var puncEndRegex = /(\W)+$/g;
+
+module.exports.textToJsx = function(text, options) {
+
+ if (options && options['singleline']) {
+ var repRegex = new RegExp("\n", "g");
+ text = text.replace(repRegex, " ");
+ } else {
+ var repRegex = new RegExp("\n", "g");
+ text = text.replace(repRegex, "<br>");
+ }
+
+ var searchTerm = ""
+ if (options && options['searchTerm']) {
+ searchTerm = options['searchTerm'].toLowerCase()
+ }
+
+ var mentionClass = "mention-highlight";
+ if (options && options['noMentionHighlight']) {
+ mentionClass = "";
+ }
+
+ var inner = [];
+
+ // Function specific regexes
+ var hashRegex = /^href="#[^"]+"|(#[A-Za-z]+[A-Za-z0-9_]*[A-Za-z0-9])$/g;
+
+ var implicitKeywords = {};
+ var keywordArray = UserStore.getCurrentMentionKeys();
+ for (var i = 0; i < keywordArray.length; i++) {
+ implicitKeywords[keywordArray[i]] = true;
+ }
+
+ var lines = text.split("<br>");
+ var urlMatcher = new LinkifyIt();
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ var words = line.split(" ");
+ var highlightSearchClass = "";
+ for (var 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 ((trimWord.toLowerCase().indexOf(searchTerm) > -1 || word.toLowerCase().indexOf(searchTerm) > -1) && searchTerm != "") {
+
+ highlightSearchClass = " search-highlight";
+ }
+
+ if (explicitMention && UserStore.getProfileByUsername(explicitMention[1])) {
+ var name = explicitMention[1];
+ // do both a non-case sensitive and case senstive check
+ var mClass = (name.toLowerCase() in implicitKeywords || name in implicitKeywords) ? mentionClass : "";
+
+ var suffix = word.match(puncEndRegex);
+ var 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={function() {module.exports.searchForTerm(name);}}>@{name}</a>{suffix} </span>);
+ } else if (urlMatcher.test(word)) {
+ var match = urlMatcher.match(word)[0];
+ var link = match.url;
+
+ var prefix = word.substring(0,word.indexOf(match.raw))
+ var suffix = word.substring(word.indexOf(match.raw)+match.raw.length);
+
+ inner.push(<span key={word+i+z+"_span"}>{prefix}<a key={word+i+z+"_link"} className={"theme" + highlightSearchClass} target="_blank" href={link}>{match.raw}</a>{suffix} </span>);
+
+ } else if (trimWord.match(hashRegex)) {
+ var suffix = word.match(puncEndRegex);
+ var prefix = word.match(puncStartRegex);
+ var mClass = trimWord in implicitKeywords ? 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={function(value) { return function() { module.exports.searchForTerm(value); } }(trimWord)}>{trimWord}</a>{suffix} </span>);
+
+ } else if (trimWord in implicitKeywords) {
+ var suffix = word.match(puncEndRegex);
+ var 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"} key={name+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}>{module.exports.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}>{module.exports.replaceHtmlEntities(word)}</span> </span>);
+ }
+ highlightSearchClass = "";
+ }
+ if (i != lines.length-1)
+ inner.push(<br key={"br_"+i+z}/>);
+ }
+
+ return inner;
+}
+
+module.exports.getFileType = function(ext) {
+ ext = ext.toLowerCase();
+ if (Constants.IMAGE_TYPES.indexOf(ext) > -1) {
+ return "image";
+ }
+
+ if (Constants.AUDIO_TYPES.indexOf(ext) > -1) {
+ return "audio";
+ }
+
+ if (Constants.VIDEO_TYPES.indexOf(ext) > -1) {
+ return "video";
+ }
+
+ if (Constants.SPREADSHEET_TYPES.indexOf(ext) > -1) {
+ return "spreadsheet";
+ }
+
+ if (Constants.CODE_TYPES.indexOf(ext) > -1) {
+ return "code";
+ }
+
+ if (Constants.WORD_TYPES.indexOf(ext) > -1) {
+ return "word";
+ }
+
+ if (Constants.EXCEL_TYPES.indexOf(ext) > -1) {
+ return "excel";
+ }
+
+ if (Constants.PDF_TYPES.indexOf(ext) > -1) {
+ return "pdf";
+ }
+
+ if (Constants.PATCH_TYPES.indexOf(ext) > -1) {
+ return "patch";
+ }
+
+ return "other";
+};
+
+module.exports.getIconClassName = function(fileType) {
+ fileType = fileType.toLowerCase();
+
+ if (fileType in Constants.ICON_FROM_TYPE)
+ return Constants.ICON_FROM_TYPE[fileType];
+
+ return "glyphicon-file";
+}
+
+module.exports.splitFileLocation = function(fileLocation) {
+ var fileSplit = fileLocation.split('.');
+ if (fileSplit.length < 2) return {};
+
+ var ext = fileSplit[fileSplit.length-1];
+ fileSplit.splice(fileSplit.length-1,1)
+ var filePath = fileSplit.join('.');
+ var filename = filePath.split('/')[filePath.split('/').length-1];
+
+ return {'ext': ext, 'name': filename, 'path': filePath};
+}
+
+module.exports.toTitleCase = function(str) {
+ return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
+}
+
+module.exports.changeCss = function(className, classValue) {
+ // we need invisible container to store additional css definitions
+ var cssMainContainer = $('#css-modifier-container');
+ if (cssMainContainer.length == 0) {
+ var cssMainContainer = $('<div id="css-modifier-container"></div>');
+ cssMainContainer.hide();
+ cssMainContainer.appendTo($('body'));
+ }
+
+ // and we need one div for each class
+ classContainer = cssMainContainer.find('div[data-class="' + className + '"]');
+ if (classContainer.length == 0) {
+ classContainer = $('<div data-class="' + className + '"></div>');
+ classContainer.appendTo(cssMainContainer);
+ }
+
+ // append additional style
+ classContainer.html('<style>' + className + ' {' + classValue + '}</style>');
+}
+
+module.exports.rgb2hex = function(rgb) {
+ if (/^#[0-9A-F]{6}$/i.test(rgb)) return rgb;
+
+ rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
+ function hex(x) {
+ return ("0" + parseInt(x).toString(16)).slice(-2);
+ }
+ return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]);
+}
+
+module.exports.placeCaretAtEnd = function(el) {
+ el.focus();
+ if (typeof window.getSelection != "undefined"
+ && typeof document.createRange != "undefined") {
+ var range = document.createRange();
+ range.selectNodeContents(el);
+ range.collapse(false);
+ var sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ } else if (typeof document.body.createTextRange != "undefined") {
+ var textRange = document.body.createTextRange();
+ textRange.moveToElementText(el);
+ textRange.collapse(false);
+ textRange.select();
+ }
+}
+
+module.exports.getCaretPosition = function(el) {
+ if (el.selectionStart) {
+ return el.selectionStart;
+ } else if (document.selection) {
+ el.focus();
+
+ var r = document.selection.createRange();
+ if (r == null) {
+ return 0;
+ }
+
+ var re = el.createTextRange(),
+ rc = re.duplicate();
+ re.moveToBookmark(r.getBookmark());
+ rc.setEndPoint('EndToStart', re);
+
+ return rc.text.length;
+ }
+ return 0;
+}
+
+module.exports.setSelectionRange = function(input, selectionStart, selectionEnd) {
+ if (input.setSelectionRange) {
+ input.focus();
+ input.setSelectionRange(selectionStart, selectionEnd);
+ }
+ else if (input.createTextRange) {
+ var range = input.createTextRange();
+ range.collapse(true);
+ range.moveEnd('character', selectionEnd);
+ range.moveStart('character', selectionStart);
+ range.select();
+ }
+}
+
+module.exports.setCaretPosition = function (input, pos) {
+ module.exports.setSelectionRange(input, pos, pos);
+}
+
+module.exports.getSelectedText = function(input) {
+ var selectedText;
+ if (document.selection != undefined) {
+ input.focus();
+ var sel = document.selection.createRange();
+ selectedText = sel.text;
+ } else if (input.selectionStart != undefined) {
+ var startPos = input.selectionStart;
+ var endPos = input.selectionEnd;
+ selectedText = input.value.substring(startPos, endPos)
+ }
+
+ return selectedText;
+}
+
+module.exports.isValidUsername = function (name) {
+
+ var error = ""
+ if (!name) {
+ error = "This field is required";
+ }
+
+ else if (name.length < 3 || name.length > 15)
+ {
+ error = "Must be between 3 and 15 characters";
+ }
+
+ else if (!/^[a-z0-9\.\-\_]+$/.test(name))
+ {
+ error = "Must contain only lowercase letters, numbers, and the symbols '.', '-', and '_'.";
+ }
+
+ else if (!/[a-z]/.test(name.charAt(0)))
+ {
+ error = "First character must be a letter.";
+ }
+
+ else
+ {
+ var lowerName = name.toLowerCase().trim();
+
+ for (var i = 0; i < Constants.RESERVED_USERNAMES.length; i++)
+ {
+ if (lowerName === Constants.RESERVED_USERNAMES[i])
+ {
+ error = "Cannot use a reserved word as a username.";
+ break;
+ }
+ }
+ }
+
+ return error;
+}
+
+module.exports.switchChannel = function(channel, teammate_name) {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.CLICK_CHANNEL,
+ name: channel.name,
+ id: channel.id
+ });
+
+ var domain = window.location.href.split('/channels')[0];
+ history.replaceState('data', '', domain + '/channels/' + channel.name);
+
+ if (channel.type === 'D' && teammate_name) {
+ document.title = teammate_name + " " + document.title.substring(document.title.lastIndexOf("-"));
+ } else {
+ document.title = channel.display_name + " " + document.title.substring(document.title.lastIndexOf("-"));
+ }
+
+ AsyncClient.getChannels(true, true, true);
+ AsyncClient.getChannelExtraInfo(true);
+ AsyncClient.getPosts(true, channel.id);
+
+ $('.inner__wrap').removeClass('move--right');
+ $('.sidebar--left').removeClass('move--right');
+
+ client.trackPage();
+
+ return false;
+}
+
+module.exports.isMobile = function() {
+ return screen.width <= 768;
+}
+
+module.exports.isComment = function(post) {
+ if ('root_id' in post) {
+ return post.root_id != "";
+ }
+ return false;
+}
+
+Image.prototype.load = function(url, progressCallback) {
+ var thisImg = this;
+ var xmlHTTP = new XMLHttpRequest();
+ xmlHTTP.open('GET', url, true);
+ xmlHTTP.responseType = 'arraybuffer';
+ xmlHTTP.onload = function(e) {
+ var h = xmlHTTP.getAllResponseHeaders(),
+ m = h.match( /^Content-Type\:\s*(.*?)$/mi ),
+ mimeType = m[ 1 ] || 'image/png';
+
+ var blob = new Blob([this.response], { type: mimeType });
+ thisImg.src = window.URL.createObjectURL(blob);
+ };
+ xmlHTTP.onprogress = function(e) {
+ parseInt(thisImg.completedPercentage = (e.loaded / e.total) * 100);
+ if (progressCallback) progressCallback();
+ };
+ xmlHTTP.onloadstart = function() {
+ thisImg.completedPercentage = 0;
+ };
+ xmlHTTP.send();
+};
+
+Image.prototype.completedPercentage = 0;