summaryrefslogtreecommitdiffstats
path: root/web/react/components
diff options
context:
space:
mode:
Diffstat (limited to 'web/react/components')
-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
61 files changed, 8987 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>
+ );
+ }
+});