diff options
Diffstat (limited to 'web/react/components')
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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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> + <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> + <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> + <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> <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> + <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> + <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">×</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">×</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">×</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">×</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">×</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">×</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> + ); + } +}); |