diff --git a/CHANGELOG.md b/CHANGELOG.md index 425902cf01..0d550f4d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,14 @@ The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the product's `master` branch to note key changes committed to master and are on their way to the next stable release. When a stable release is pushed the "UNDER DEVELOPMENT" heading is removed from the final changelog of the release. +- **Release candidate anticipated:** 2015-11-10 - **Final release anticipated:** 2015-11-16 +### Changes + +- IE 10 no longer supported since global share of IE 10 fell below 5% + + ## Release v1.1.0 Released: 2015-10-16 diff --git a/README.md b/README.md index 08f01f7178..eb273b7ecd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Please see the [features pages of the Mattermost website](http://www.mattermost. - Attach sound, video and image files from mobile devices - Define team-specific branding and color themes across your devices +#### Self-Host Ready + +- Host and manage dozens of teams from a single Mattermost server +- Easily manage your Mattermost server using a web-based System Console +- Script setup and maintenance using Mattermost command line tools + ## Learn More - [Product Vision and Target Audiences](http://www.mattermost.org/vision/) - What we're solving and for whom are we building @@ -38,7 +44,9 @@ Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq). ## Installing Mattermost -There are multiple ways to install Mattermost depending on your needs. +Latest stable release of Mattermost is available from http://www.mattermost.org/download/, including binary distribution, and from install guides below. + +If you use Docker, you can [install Mattermost in a single-container preview in one line](https://github.com/mattermost/platform/blob/master/doc/install/Docker-Single-Container.md#one-line-docker-install). #### Quick Start Install for Product Evaluation diff --git a/api/channel.go b/api/channel.go index adf1253780..70f7eba4bf 100644 --- a/api/channel.go +++ b/api/channel.go @@ -568,7 +568,7 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) { Srv.Store.Channel().UpdateLastViewedAt(id, c.Session.UserId) - message := model.NewMessage(c.Session.TeamId, id, c.Session.UserId, model.ACTION_VIEWED) + message := model.NewMessage(c.Session.TeamId, id, c.Session.UserId, model.ACTION_CHANNEL_VIEWED) message.Add("channel_id", id) PublishAndForget(message) @@ -777,9 +777,8 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel UpdateChannelAccessCacheAndForget(channel.TeamId, userIdToRemove, channel.Id) - message := model.NewMessage(channel.TeamId, "", userIdToRemove, model.ACTION_USER_REMOVED) - message.Add("channel_id", channel.Id) - message.Add("remover", removerUserId) + message := model.NewMessage(channel.TeamId, channel.Id, userIdToRemove, model.ACTION_USER_REMOVED) + message.Add("remover_id", removerUserId) PublishAndForget(message) return nil diff --git a/api/web_conn.go b/api/web_conn.go index a5099e5202..50a003acea 100644 --- a/api/web_conn.go +++ b/api/web_conn.go @@ -92,24 +92,9 @@ func (c *WebConn) writePump() { return } - if len(msg.ChannelId) > 0 { - allowed, ok := c.ChannelAccessCache[msg.ChannelId] - if !ok { - allowed = hasPermissionsToChannel(Srv.Store.Channel().CheckPermissionsTo(c.TeamId, msg.ChannelId, c.UserId)) - c.ChannelAccessCache[msg.ChannelId] = allowed - } - - if allowed { - c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) - if err := c.WebSocket.WriteJSON(msg); err != nil { - return - } - } - } else { - c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) - if err := c.WebSocket.WriteJSON(msg); err != nil { - return - } + c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) + if err := c.WebSocket.WriteJSON(msg); err != nil { + return } case <-ticker.C: @@ -121,9 +106,11 @@ func (c *WebConn) writePump() { } } -func (c *WebConn) updateChannelAccessCache(channelId string) { +func (c *WebConn) updateChannelAccessCache(channelId string) bool { allowed := hasPermissionsToChannel(Srv.Store.Channel().CheckPermissionsTo(c.TeamId, channelId, c.UserId)) c.ChannelAccessCache[channelId] = allowed + + return allowed } func hasPermissionsToChannel(sc store.StoreChannel) bool { diff --git a/api/web_team_hub.go b/api/web_team_hub.go index c57de550fa..6a25b7d3da 100644 --- a/api/web_team_hub.go +++ b/api/web_team_hub.go @@ -53,7 +53,7 @@ func (h *TeamHub) Start() { } case msg := <-h.broadcast: for webCon := range h.connections { - if !(webCon.UserId == msg.UserId && msg.Action == model.ACTION_TYPING) { + if ShouldSendEvent(webCon, msg) { select { case webCon.Send <- msg: default: @@ -86,3 +86,32 @@ func (h *TeamHub) UpdateChannelAccessCache(userId string, channelId string) { } } } + +func ShouldSendEvent(webCon *WebConn, msg *model.Message) bool { + + if webCon.UserId == msg.UserId { + // Don't need to tell the user they are typing + if msg.Action == model.ACTION_TYPING { + return false + } + } else { + // Don't share a user's view events with other users + if msg.Action == model.ACTION_CHANNEL_VIEWED { + return false + } + + // Only report events to a user who is the subject of the event, or is in the channel of the event + if len(msg.ChannelId) > 0 { + allowed, ok := webCon.ChannelAccessCache[msg.ChannelId] + if !ok { + allowed = webCon.updateChannelAccessCache(msg.ChannelId) + } + + if !allowed { + return false + } + } + } + + return true +} diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md index 87f8edb848..836af3995f 100644 --- a/doc/install/Production-Ubuntu.md +++ b/doc/install/Production-Ubuntu.md @@ -160,10 +160,11 @@ exec bin/platform proxy_set_header X-Forwarded-Ssl on; ``` ## Finish Mattermost Server setup + 1. Navigate to https://mattermost.example.com and create a team and user. 1. The first user in the system is automatically granted the `system_admin` role, which gives you access to the System Console. 1. From the `town-square` channel click the dropdown and choose the `System Console` option -1. Update Email Settings. We recommend using an email sending service. The example below assumes AmazonSES. +1. Update Email Settings. We recommend using an email sending service. The example shows how an Amazon SES setup might look (sample credentials shown below are not real). * Set *Send Email Notifications* to true * Set *Require Email Verification* to true * Set *Feedback Name* to `No-Reply` diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md index b87663ab35..6a7260ddfa 100644 --- a/doc/install/Troubleshooting.md +++ b/doc/install/Troubleshooting.md @@ -5,3 +5,8 @@ 1. **DO NOT manipulate the Mattermost database** - In particular, DO NOT delete data from the database, as Mattermost is designed to stop working if data integrity has been compromised. The system is designed to archive content continously and generally assumes data is never deleted. + +#### Common Issues + +1. Error message in logs when attempting to sign-up: `x509: certificate signed by unknown authority` + - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority). diff --git a/model/message.go b/model/message.go index 122af4d9c2..2725353ac8 100644 --- a/model/message.go +++ b/model/message.go @@ -9,14 +9,14 @@ import ( ) const ( - ACTION_TYPING = "typing" - ACTION_POSTED = "posted" - ACTION_POST_EDITED = "post_edited" - ACTION_POST_DELETED = "post_deleted" - ACTION_VIEWED = "viewed" - ACTION_NEW_USER = "new_user" - ACTION_USER_ADDED = "user_added" - ACTION_USER_REMOVED = "user_removed" + ACTION_TYPING = "typing" + ACTION_POSTED = "posted" + ACTION_POST_EDITED = "post_edited" + ACTION_POST_DELETED = "post_deleted" + ACTION_CHANNEL_VIEWED = "channel_viewed" + ACTION_NEW_USER = "new_user" + ACTION_USER_ADDED = "user_added" + ACTION_USER_REMOVED = "user_removed" ) type Message struct { diff --git a/utils/mail.go b/utils/mail.go index c91c15a6a2..07a79eeb29 100644 --- a/utils/mail.go +++ b/utils/mail.go @@ -6,15 +6,22 @@ package utils import ( l4g "code.google.com/p/log4go" "crypto/tls" + "encoding/base64" "fmt" "github.com/mattermost/platform/model" - "html" "net" "net/mail" "net/smtp" "time" ) +func encodeRFC2047Word(s string) string { + // TODO: use `mime.BEncoding.Encode` instead when `go` >= 1.5 + // return mime.BEncoding.Encode("utf-8", s) + dst := base64.StdEncoding.EncodeToString([]byte(s)) + return "=?utf-8?b?" + dst + "?=" +} + func connectToSMTPServer(config *model.Config) (net.Conn, *model.AppError) { var conn net.Conn var err error @@ -102,9 +109,10 @@ func SendMailUsingConfig(to, subject, body string, config *model.Config) *model. headers := make(map[string]string) headers["From"] = fromMail.String() headers["To"] = toMail.String() - headers["Subject"] = html.UnescapeString(subject) + headers["Subject"] = encodeRFC2047Word(subject) headers["MIME-version"] = "1.0" - headers["Content-Type"] = "text/html" + headers["Content-Type"] = "text/html; charset=\"utf-8\"" + headers["Content-Transfer-Encoding"] = "8bit" headers["Date"] = time.Now().Format(time.RFC1123Z) message := "" diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx index 74d6c64e3d..2c944913f8 100644 --- a/web/react/components/activity_log_modal.jsx +++ b/web/react/components/activity_log_modal.jsx @@ -81,6 +81,7 @@ export default class ActivityLogModal extends React.Component { const currentSession = this.state.sessions[i]; const lastAccessTime = new Date(currentSession.last_activity_at); const firstAccessTime = new Date(currentSession.create_at); + let devicePlatform = currentSession.props.platform; let devicePicture = ''; if (currentSession.props.platform === 'Windows') { @@ -88,7 +89,12 @@ export default class ActivityLogModal extends React.Component { } else if (currentSession.props.platform === 'Macintosh' || currentSession.props.platform === 'iPhone') { devicePicture = 'fa fa-apple'; } else if (currentSession.props.platform === 'Linux') { - devicePicture = 'fa fa-linux'; + if (currentSession.props.os.indexOf('Android') >= 0) { + devicePlatform = 'Android'; + devicePicture = 'fa fa-android'; + } else { + devicePicture = 'fa fa-linux'; + } } let moreInfo; @@ -119,7 +125,7 @@ export default class ActivityLogModal extends React.Component { className='activity-log__table' >
-
{currentSession.props.platform}
+
{devicePlatform}
{`Last activity: ${lastAccessTime.toDateString()}, ${lastAccessTime.toLocaleTimeString()}`}
{moreInfo} diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index f2fb8ac789..f770d166c7 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -40,9 +40,13 @@ export default class AdminController extends React.Component { config: AdminStore.getConfig(), teams: AdminStore.getAllTeams(), selectedTeams, - selected: 'service_settings', - selectedTeam: null + selected: props.tab || 'service_settings', + selectedTeam: props.teamId || null }; + + if (!props.tab) { + history.replaceState(null, null, `/admin_console/${this.state.selected}`); + } } componentDidMount() { @@ -142,7 +146,9 @@ export default class AdminController extends React.Component { } else if (this.state.selected === 'service_settings') { tab = ; } else if (this.state.selected === 'team_users') { - tab = ; + if (this.state.teams) { + tab = ; + } } } diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 4c2a473b61..b0e01ff179 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -24,6 +24,7 @@ export default class AdminSidebar extends React.Component { handleClick(name, teamId, e) { e.preventDefault(); this.props.selectTab(name, teamId); + history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`); } isSelected(name, teamId) { @@ -53,6 +54,9 @@ export default class AdminSidebar extends React.Component { } componentDidMount() { + if ($(window).width() > 768) { + $('.nav-pills__container').perfectScrollbar(); + } } showTeamSelect(e) { diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index 01759b2220..40e00ff047 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -440,9 +440,11 @@ export default class EmailSettings extends React.Component { className='table table-bordered' cellPadding='5' > - {'None'}{'Mattermost will send email over an unsecure connection.'} - {'TLS'}{'Encrypts the communication between Mattermost and your email server.'} - {'STARTTLS'}{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'} + + {'None'}{'Mattermost will send email over an unsecure connection.'} + {'TLS'}{'Encrypts the communication between Mattermost and your email server.'} + {'STARTTLS'}{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'} +
diff --git a/web/react/components/admin_console/log_settings.jsx b/web/react/components/admin_console/log_settings.jsx index 931818bb82..7e9eda89b1 100644 --- a/web/react/components/admin_console/log_settings.jsx +++ b/web/react/components/admin_console/log_settings.jsx @@ -249,22 +249,24 @@ export default class LogSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.fileEnable} /> -

+

{'Format of log message output. If blank will be set to "[%D %T] [%L] %M", where:'}
- - - - - - + + + + + + + +
{'%T'}{'Time (15:04:05 MST)'}
{'%D'}{'Date (2006/01/02)'}
{'%d'}{'Date (01/02/06)'}
{'%L'}{'Level (DEBG, INFO, EROR)'}
{'%S'}{'Source'}
{'%M'}{'Message'}
{'%T'}{'Time (15:04:05 MST)'}
{'%D'}{'Date (2006/01/02)'}
{'%d'}{'Date (01/02/06)'}
{'%L'}{'Level (DEBG, INFO, EROR)'}
{'%S'}{'Source'}
{'%M'}{'Message'}
-

+
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 7582de6c4a..1b709336f6 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -4,7 +4,6 @@ const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const PostStore = require('../stores/post_store.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); const NavbarSearchBox = require('./search_bar.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); @@ -25,7 +24,6 @@ export default class ChannelHeader extends React.Component { super(props); this.onListenerChange = this.onListenerChange.bind(this); - this.onSocketChange = this.onSocketChange.bind(this); this.handleLeave = this.handleLeave.bind(this); this.searchMentions = this.searchMentions.bind(this); @@ -45,7 +43,6 @@ export default class ChannelHeader extends React.Component { ChannelStore.addExtraInfoChangeListener(this.onListenerChange); PostStore.addSearchChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); - SocketStore.addChangeListener(this.onSocketChange); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); @@ -60,16 +57,9 @@ export default class ChannelHeader extends React.Component { } $('.channel-header__info .description').popover({placement: 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}}); } - onSocketChange(msg) { - if (msg.action === 'new_user' || - msg.action === 'user_added' || - (msg.action === 'user_removed' && msg.user_id !== UserStore.getCurrentId())) { - AsyncClient.getChannelExtraInfo(true); - } - } handleLeave() { Client.leaveChannel(this.state.channel.id, - function handleLeaveSuccess() { + () => { AppDispatcher.handleViewAction({ type: ActionTypes.LEAVE_CHANNEL, id: this.state.channel.id @@ -77,8 +67,8 @@ export default class ChannelHeader extends React.Component { const townsquare = ChannelStore.getByName('town-square'); Utils.switchChannel(townsquare); - }.bind(this), - function handleLeaveError(err) { + }, + (err) => { AsyncClient.dispatchError(err, 'handleLeave'); } ); diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index 90d9696e7d..b259b3c18a 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -70,7 +70,7 @@ export default class EditPostModal extends React.Component { refocusId: options.refocusId || '' }); - $(React.findDOMNode(this.refs.modal)).modal('show'); + $(ReactDOM.findDOMNode(this.refs.modal)).modal('show'); } componentDidMount() { var self = this; @@ -92,7 +92,7 @@ export default class EditPostModal extends React.Component { $('#edit_textbox').get(0).focus(); }); - $(React.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() { + $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() { if (self.state.refocusId !== '') { setTimeout(() => { $(self.state.refocusId).get(0).focus(); diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index a20c5cad5d..a0084ad30a 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -83,7 +83,7 @@ export default class MoreChannels extends React.Component { moreChannels = ; } else if (channels.length) { moreChannels = ( - +
{channels.map(function cMap(channel, index) { var joinButton; @@ -108,8 +108,8 @@ export default class MoreChannels extends React.Component { return ( + + + ); } + componentDidUpdate(prevProps) { + if (!prevProps.show && this.props.show) { + $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300); + if ($(window).width() > 768) { + $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); + } + } + } + render() { if (!this.props.show) { return null; @@ -213,7 +220,7 @@ export default class MoreDirectChannels extends React.Component { const userEntries = users.map(this.createRowForUser); if (userEntries.length === 0) { - userEntries.push(
  • {'No users found :('}
  • ); + userEntries.push(); } let memberString = 'Member'; @@ -232,26 +239,35 @@ export default class MoreDirectChannels extends React.Component { - {'More Direct Messages'} + {'Team Directory'} -
    - - {count} +
    +
    + +
    +
    + {count} +
    +
    +
    +
    -

    {channel.display_name}

    -

    {channel.description}

    +

    {channel.display_name}

    +

    {channel.description}

    {joinButton} diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 08b64de8be..1051990352 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -140,12 +140,12 @@ export default class MoreDirectChannels extends React.Component { if (user.nickname) { const separator = fullName ? ' - ' : ''; details.push( - {separator + user.nickname} - +

    ); } @@ -170,31 +170,38 @@ export default class MoreDirectChannels extends React.Component { } return ( -
  • -
    +
  • - -
    -
    +
    {user.username}
    -
    - {details} -
    -
    -
    + {details} +
    {joinButton} - - +
    {'No users found :('}
    + + {userEntries} + +
    -
      - {userEntries} -

    + > {'Display Settings'}

    diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx index 62738efbd8..61d49acb2a 100644 --- a/web/react/components/user_settings/user_settings_notifications.jsx +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -228,9 +228,8 @@ export default class NotificationsTab extends React.Component { - For all activity - + /> + {'For all activity'}
    @@ -240,9 +239,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={notifyActive[1]} onChange={this.handleNotifyRadio.bind(this, 'mention')} - > - Only for mentions and direct messages - + /> + {'Only for mentions and direct messages'}
    @@ -252,9 +250,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={notifyActive[2]} onChange={this.handleNotifyRadio.bind(this, 'none')} - > - Never - + /> + {'Never'} @@ -320,9 +317,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={soundActive[0]} onChange={this.handleSoundRadio.bind(this, 'true')} - > - On - + /> + {'On'}
    @@ -332,9 +328,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={soundActive[1]} onChange={this.handleSoundRadio.bind(this, 'false')} - > - Off - + /> + {'Off'}
    @@ -402,9 +397,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={emailActive[0]} onChange={this.handleEmailRadio.bind(this, 'true')} - > - On - + /> + {'On'}
    @@ -414,9 +408,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={emailActive[1]} onChange={this.handleEmailRadio.bind(this, 'false')} - > - Off - + /> + {'Off'}
    @@ -482,9 +475,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.firstNameKey} onChange={handleUpdateFirstNameKey} - > - {'Your case sensitive first name "' + user.first_name + '"'} - + /> + {'Your case sensitive first name "' + user.first_name + '"'} @@ -502,9 +494,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.usernameKey} onChange={handleUpdateUsernameKey} - > - {'Your non-case sensitive username "' + user.username + '"'} - + /> + {'Your non-case sensitive username "' + user.username + '"'} @@ -521,9 +512,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.mentionKey} onChange={handleUpdateMentionKey} - > - {'Your username mentioned "@' + user.username + '"'} - + /> + {'Your username mentioned "@' + user.username + '"'} @@ -540,9 +530,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.allKey} onChange={handleUpdateAllKey} - > - {'Team-wide mentions "@all"'} - + /> + {'Team-wide mentions "@all"'} @@ -559,9 +548,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.channelKey} onChange={handleUpdateChannelKey} - > - {'Channel-wide mentions "@channel"'} - + /> + {'Channel-wide mentions "@channel"'} @@ -576,9 +564,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.customKeysChecked} onChange={this.updateCustomMentionKeys} - > - {'Other non-case sensitive words, separated by commas:'} - + /> + {'Other non-case sensitive words, separated by commas:'} , + , document.getElementById('admin_controller') ); diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 77e7067ad3..77951f2145 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -1,15 +1,22 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var UserStore = require('./user_store.jsx'); -var ErrorStore = require('./error_store.jsx'); -var EventEmitter = require('events').EventEmitter; +const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +const UserStore = require('./user_store.jsx'); +const PostStore = require('./post_store.jsx'); +const ChannelStore = require('./channel_store.jsx'); +const BrowserStore = require('./browser_store.jsx'); +const ErrorStore = require('./error_store.jsx'); +const EventEmitter = require('events').EventEmitter; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +const Utils = require('../utils/utils.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); -var CHANGE_EVENT = 'change'; +const Constants = require('../utils/constants.jsx'); +const ActionTypes = Constants.ActionTypes; +const SocketEvents = Constants.SocketEvents; + +const CHANGE_EVENT = 'change'; var conn; @@ -94,6 +101,39 @@ class SocketStoreClass extends EventEmitter { removeChangeListener(callback) { this.removeListener(CHANGE_EVENT, callback); } + handleMessage(msg) { + switch (msg.action) { + case SocketEvents.POSTED: + handleNewPostEvent(msg); + break; + + case SocketEvents.POST_EDITED: + handlePostEditEvent(msg); + break; + + case SocketEvents.POST_DELETED: + handlePostDeleteEvent(msg); + break; + + case SocketEvents.NEW_USER: + handleNewUserEvent(); + break; + + case SocketEvents.USER_ADDED: + handleUserAddedEvent(msg); + break; + + case SocketEvents.USER_REMOVED: + handleUserRemovedEvent(msg); + break; + + case SocketEvents.CHANNEL_VIEWED: + handleChannelViewedEvent(msg); + break; + + default: + } + } sendMessage(msg) { if (conn && conn.readyState === WebSocket.OPEN) { conn.send(JSON.stringify(msg)); @@ -104,6 +144,138 @@ class SocketStoreClass extends EventEmitter { } } +function handleNewPostEvent(msg) { + // Store post + const post = JSON.parse(msg.props.post); + PostStore.storePost(post); + + // Update channel state + if (ChannelStore.getCurrentId() === msg.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(); + } + } else { + AsyncClient.getChannel(msg.channel_id); + } + + // Send desktop notification + if (UserStore.getCurrentId() !== msg.user_id) { + const msgProps = msg.props; + + let mentions = []; + if (msgProps.mentions) { + mentions = JSON.parse(msg.props.mentions); + } + + const channel = ChannelStore.get(msg.channel_id); + const user = UserStore.getCurrentUser(); + const member = ChannelStore.getMember(msg.channel_id); + + let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; + if (notifyLevel === 'default') { + notifyLevel = user.notify_props.desktop; + } + + if (notifyLevel === 'none') { + return; + } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== 'D') { + return; + } + + let username = 'Someone'; + if (UserStore.hasProfile(msg.user_id)) { + username = UserStore.getProfile(msg.user_id).username; + } + + let title = 'Posted'; + if (channel) { + title = channel.display_name; + } + + let notifyText = post.message.replace(/\n+/g, ' '); + if (notifyText.length > 50) { + notifyText = notifyText.substring(0, 49) + '...'; + } + + if (notifyText.length === 0) { + if (msgProps.image) { + Utils.notifyMe(title, username + ' uploaded an image', channel); + } else if (msgProps.otherFile) { + Utils.notifyMe(title, username + ' uploaded a file', channel); + } else { + Utils.notifyMe(title, username + ' did something new', channel); + } + } else { + Utils.notifyMe(title, username + ' wrote: ' + notifyText, channel); + } + if (!user.notify_props || user.notify_props.desktop_sound === 'true') { + Utils.ding(); + } + } +} + +function handlePostEditEvent(msg) { + // Store post + const post = JSON.parse(msg.props.post); + PostStore.storePost(post); + + // Update channel state + if (ChannelStore.getCurrentId() === msg.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(); + } + } +} + +function handlePostDeleteEvent(msg) { + const post = JSON.parse(msg.props.post); + + PostStore.storeUnseenDeletedPost(post); + PostStore.removePost(post, true); + PostStore.emitChange(); +} + +function handleNewUserEvent() { + AsyncClient.getProfiles(); + AsyncClient.getChannelExtraInfo(true); +} + +function handleUserAddedEvent(msg) { + if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(true); + } + + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + +function handleUserRemovedEvent(msg) { + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannels(); + + if (msg.props.remover_id !== msg.user_id && + msg.channel_id === ChannelStore.getCurrentId() && + $('#removed_from_channel').length > 0) { + var sentState = {}; + sentState.channelName = ChannelStore.getCurrent().display_name; + sentState.remover = UserStore.getProfile(msg.props.remover_id).username; + + BrowserStore.setItem('channel-removed-state', sentState); + $('#removed_from_channel').modal('show'); + } + } else if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(true); + } +} + +function handleChannelViewedEvent(msg) { + // Useful for when multiple devices have the app open to different channels + if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + var SocketStore = new SocketStoreClass(); SocketStore.dispatchToken = AppDispatcher.register((payload) => { @@ -111,6 +283,7 @@ SocketStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECIEVED_MSG: + SocketStore.handleMessage(action.msg); SocketStore.emitChange(action.msg); break; diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index d6da91fe25..0911412d8a 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -46,6 +46,18 @@ module.exports = { SERVER_ACTION: null, VIEW_ACTION: null }), + + SocketEvents: { + POSTED: 'posted', + POST_EDITED: 'post_edited', + POST_DELETED: 'post_deleted', + CHANNEL_VIEWED: 'channel_viewed', + NEW_USER: 'new_user', + USER_ADDED: 'user_added', + USER_REMOVED: 'user_removed', + TYPING: 'typing' + }, + SPECIAL_MENTIONS: ['all', 'channel'], CHARACTER_LIMIT: 4000, IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'], diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 561c2c4c43..38ac68d587 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -13,7 +13,8 @@ var client = require('./client.jsx'); var Autolinker = require('autolinker'); export function isEmail(email) { - var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/; + //var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/; + var regex = /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i; return regex.test(email); } diff --git a/web/sass-files/sass/partials/_command-box.scss b/web/sass-files/sass/partials/_command-box.scss index f1aa4dca2d..184fb55ebd 100644 --- a/web/sass-files/sass/partials/_command-box.scss +++ b/web/sass-files/sass/partials/_command-box.scss @@ -5,6 +5,7 @@ border: $border-gray; bottom: 38px; overflow: auto; + z-index: 100; @extend %popover-box-shadow; .sidebar--right & { bottom: 100px; diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss index b942a5a40a..0e474a1e23 100644 --- a/web/sass-files/sass/partials/_modal.scss +++ b/web/sass-files/sass/partials/_modal.scss @@ -140,7 +140,7 @@ padding: 0; } } - .more-channel-table { + .more-table { margin: 0; table-layout: fixed; p { @@ -150,9 +150,11 @@ @include opacity(0.8); margin: 5px 0; } - .more-channel-name { + .more-name { font-weight: 600; font-size: 0.95em; + overflow: hidden; + text-overflow: ellipsis; } tbody { > tr { @@ -175,6 +177,9 @@ padding: 8px 15px 8px 8px; width: 80px; vertical-align: middle; + &.lg { + width: 110px; + } } } } @@ -331,47 +336,42 @@ } .modal-direct-channels { - .user-list { - list-style-type: none; - margin: 15px 0px 0px; - max-height: 600px; - padding: 0px; - overflow: auto; - li { - border-bottom: 1px solid #ddd; - height: 60px; - padding: 10px 0px; + .user-list { + margin-top: 20px; + overflow: auto; + -webkit-overflow-scrolling: touch; + max-height: 500px; + position: relative; + } - .image-div { - padding: 0px; + .table { + margin-top: 10px; + } - .profile-image { - width: 40px; - height: 40px; - @include border-radius(20px); - } - } + .modal-body { + padding: 20px 0 0; + @include clearfix; + } - .username { - font-weight: bold; - } + .filter-row { + padding: 0 15px; + } - .nickname { - color: #888; - } + .member-count { + margin-top: 5px; + float: right; + @include opacity(0.8); + } - .btn-div { - padding: 0px; - .btn-message { - position: relative; - top: 5px; - } - } + .more-description { + @include opacity(0.7); + } - &:last-child { - border-bottom: 0px; - } - } - } + .profile-img { + -moz-border-radius: 50px; + -webkit-border-radius: 50px; + border-radius: 50px; + margin-right: 8px; + } } diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss index 09f2c179ea..c41199cac3 100644 --- a/web/sass-files/sass/partials/_responsive.scss +++ b/web/sass-files/sass/partials/_responsive.scss @@ -270,6 +270,13 @@ height: auto; } } + .modal-direct-channels { + .member-count { + float: none; + margin-top: 10px; + display: block; + } + } .center-file-overlay { font-size: 1.3em; } diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss index 0c2f25eab7..3146c16d57 100644 --- a/web/sass-files/sass/partials/_settings.scss +++ b/web/sass-files/sass/partials/_settings.scss @@ -230,13 +230,6 @@ font-weight:500; } -.profile-img { - width:128px; - height:128px; - margin-bottom: 10px; - @include border-radius(128px); -} - .sel-btn { margin-right:5px; } diff --git a/web/sass-files/sass/partials/_videos.scss b/web/sass-files/sass/partials/_videos.scss index 9e1ce29b7a..6ae5b488b6 100644 --- a/web/sass-files/sass/partials/_videos.scss +++ b/web/sass-files/sass/partials/_videos.scss @@ -26,11 +26,6 @@ padding:0px; } -.video-uploader { - font-size: 13px; - margin: 0 0 15px; -} - .video-title { font-size:15px; margin-top:3px; @@ -54,4 +49,4 @@ border-top:36px solid transparent; border-bottom:36px solid transparent; border-left:60px solid rgba(255,255,255,0.4); -} \ No newline at end of file +} diff --git a/web/templates/admin_console.html b/web/templates/admin_console.html index a046478f60..574caf730f 100644 --- a/web/templates/admin_console.html +++ b/web/templates/admin_console.html @@ -12,7 +12,7 @@