diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..8ffce2a9e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +## Contributing Code + +Please see [Mattermost Code Contribution Guidelines](https://github.com/mattermost/platform/blob/master/doc/developer/Code-Contribution-Guidelines.md) diff --git a/api/templates/password_change_body.html b/api/templates/password_change_body.html index 47a93fcb93..2e1df3ff2a 100644 --- a/api/templates/password_change_body.html +++ b/api/templates/password_change_body.html @@ -18,7 +18,7 @@

You updated your password

-

You updated your password for {{.Props.TeamDisplayName}} on {{ .Props.TeamURL }} by {{.Props.Method}}.
If this change wasn't initiated by you, please reply to this email and let us know.

+

You updated your password for {{.Props.TeamDisplayName}} on {{ .Props.TeamURL }} by {{.Props.Method}}.
If this change wasn't initiated by you, please contact your system administrator.

diff --git a/doc/help/Search.md b/doc/help/Search.md index f36e079bdc..02ecf7d406 100644 --- a/doc/help/Search.md +++ b/doc/help/Search.md @@ -8,4 +8,8 @@ Some things to know about search: - You can use quotes to return search results for exact terms, like `"Mattermost website"` will only return messages containing the entire phrase `"Mattermost website"` and not return messages with only `Mattermost` or `website` - You can use the `*` character for wildcard searches that match within words. For example: Searching for `rea*` brings back messages containing `reach`, `reason` and other words starting with `rea`. -Search in Mattermost uses the full text search features in MySQL and Postgres databases. Special cases that are not supported in default full text search, such as searching for IP addresses like `10.100.200.101`, can be added in future as the search feature evolves. +#### Limitations + +- Search in Mattermost uses the full text search features included in either a MySQL or Postgres database, which has some limitations + - Special cases that are not supported in default full text search, such as searching for IP addresses like `10.100.200.101`, can be added in future as the search feature evolves + - Searches with fewer than three characters will return no results, so for searching in Chinese try adding * to the end of queries diff --git a/doc/install/SMTP-Email-Setup.md b/doc/install/SMTP-Email-Setup.md index b958a6a964..bb57d95ba2 100644 --- a/doc/install/SMTP-Email-Setup.md +++ b/doc/install/SMTP-Email-Setup.md @@ -12,7 +12,8 @@ To enable email, configure an SMTP email service as follows: 2. If you don't have an SMTP service, here are simple instructions to set one up with [Amazon Simple Email Service (SES)](https://aws.amazon.com/ses/): 2. Go to [Amazon SES console](https://console.aws.amazon.com/ses) then `SMTP Settings > Create My SMTP Credentials` 3. Copy the `Server Name`, `Port`, `SMTP Username`, and `SMTP Password` for Step 2 below. - 4. From the `Domains` menu set up and verify a new domain, then enable `Generate DKIM Settings` for the domain. + 4. From the `Domains` menu set up and verify a new domain, then enable `Generate DKIM Settings` for the domain. + 1. We recommend you set up _[Sender Policy Framework](https://en.wikipedia.org/wiki/Sender_Policy_Framework) (SPF)_ and/or _[Domain Keys Identified Mail](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) (DKIM)_ for your email domain. 5. Choose an sender address like `mattermost@example.com` and click `Send a Test Email` to verify setup is working correctly. 2. **Configure SMTP settings** @@ -57,7 +58,11 @@ To enable email, configure an SMTP email service as follows: * Information needed ##### Hotmail -* Information needed +* Set **SMTP Username** to **your_email@hotmail.com** +* Set **SMTP Password** to **your_password** +* Set **SMTP Server** to **smtp-mail.outlook.com** +* Set **SMTP Port** to **587** +* Set **Connection Security** to **STARTTLS** ### Troubleshooting SMTP @@ -91,4 +96,4 @@ Connected to mail.example.com. 250-STARTTLS 250-PIPELINING 250 8BITMIME -``` \ No newline at end of file +``` diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx index 6151d4bdd7..43700bf361 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications.jsx @@ -136,16 +136,15 @@ export default class ChannelNotifications extends React.Component { var inputs = []; inputs.push( -
+

@@ -155,9 +154,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[1]} onChange={this.handleUpdateNotifyLevel.bind(this, 'all')} - > + /> {'For all activity'} -
@@ -167,9 +165,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[2]} onChange={this.handleUpdateNotifyLevel.bind(this, 'mention')} - > + /> {'Only for mentions'} -
@@ -179,9 +176,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[3]} onChange={this.handleUpdateNotifyLevel.bind(this, 'none')} - > + /> {'Never'} - @@ -274,16 +270,15 @@ export default class ChannelNotifications extends React.Component { if (this.state.activeSection === 'markUnreadLevel') { const inputs = [( -
+

@@ -293,9 +288,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={this.state.markUnreadLevel === 'mention'} onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'mention')} - > + /> {'Only for mentions'} -
@@ -370,7 +364,7 @@ export default class ChannelNotifications extends React.Component { data-dismiss='modal' > - Close + {'Close'}

Notification Preferences for {this.state.title}

diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index 4986f88b6f..86a4b04cfb 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -260,6 +260,12 @@ export default class InviteMemberModal extends React.Component { var content = null; var sendButton = null; + + var sendButtonLabel = 'Send Invitation'; + if (this.state.inviteIds.length > 1) { + sendButtonLabel = 'Send Invitations'; + } + if (this.state.emailEnabled) { content = (
@@ -281,7 +287,7 @@ export default class InviteMemberModal extends React.Component { onClick={this.handleSubmit} type='button' className='btn btn-primary' - >Send Invitations + >{sendButtonLabel} ); } else { var teamInviteLink = null; diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 743e308542..41746d1d7c 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -169,7 +169,7 @@ export default class MoreDirectChannels extends React.Component { } return ( - + 0)) { + this.setState({ + show: false + }); + } + } + + handleInputChange(textbox, text) { + const caret = Utils.getCaretPosition(textbox); + const preText = text.substring(0, caret); + + let mode = ''; + let filter = ''; + for (const [modeForPattern, pattern] of patterns) { + const result = pattern.exec(preText); + + if (result) { + mode = modeForPattern; + filter = result[1]; + break; + } + } + + if (mode !== this.state.mode || filter !== this.state.filter) { + this.updateSuggestions(mode, filter); + } + + this.setState({ + mode, + filter, + show: mode || filter + }); + } + + handleKeyDown(e) { + if (!this.state.show || this.state.suggestions.length === 0) { + return; + } + + if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) { + e.preventDefault(); + + let selection = this.state.selection; + + if (e.which === KeyCodes.UP) { + selection -= 1; + } else { + selection += 1; + } + + if (selection >= 0 && selection < this.state.suggestions.length) { + this.setState({ + selection + }); + } + } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { + e.preventDefault(); + + this.completeSelectedWord(); + } + } + + completeSelectedWord() { + if (this.state.mode === 'channels') { + this.completeWord(this.state.suggestions[this.state.selection].name); + } else if (this.state.mode === 'users') { + this.completeWord(this.state.suggestions[this.state.selection].username); + } + } + + completeWord(value) { + // add a space so that anything else typed doesn't interfere with the search flag + this.props.completeWord(this.state.filter, value + ' '); + + this.setState({ + show: false, + mode: '', + filter: '', + selection: 0 + }); + } + + updateSuggestions(mode, filter) { + let suggestions = []; + + if (mode === 'channels') { + let channels = ChannelStore.getAll(); + + if (filter) { + channels = channels.filter((channel) => channel.name.startsWith(filter)); + } + + channels.sort((a, b) => a.name.localeCompare(b.name)); + + suggestions = channels; + } else if (mode === 'users') { + let users = UserStore.getActiveOnlyProfileList(); + + if (filter) { + users = users.filter((user) => user.username.startsWith(filter)); + } + + users.sort((a, b) => a.username.localeCompare(b.username)); + + suggestions = users; + } + + let selection = this.state.selection; + + // keep the same user/channel selected if it's still visible as a suggestion + if (selection > 0 && this.state.suggestions.length > 0) { + // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects + const currentSelectionId = this.state.suggestions[selection].id; + let found = false; + + for (let i = 0; i < suggestions.length; i++) { + if (suggestions[i].id === currentSelectionId) { + selection = i; + found = true; + + break; + } + } + + if (!found) { + selection = 0; + } + } else { + selection = 0; + } + + this.setState({ + suggestions, + selection + }); + } + + render() { + if (!this.state.show || this.state.suggestions.length === 0) { + return null; + } + + let suggestions = []; + + if (this.state.mode === 'channels') { + suggestions = this.state.suggestions.map((channel, index) => { + let className = 'search-autocomplete__channel'; + if (this.state.selection === index) { + className += ' selected'; + } + + return ( +
+ {channel.name} +
+ ); + }); + } else if (this.state.mode === 'users') { + suggestions = this.state.suggestions.map((user, index) => { + let className = 'search-autocomplete__user'; + if (this.state.selection === index) { + className += ' selected'; + } + + return ( +
+ + {suggestions} +
+ ); + } +} + +SearchAutocomplete.propTypes = { + completeWord: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 7540b41a47..e1d36ad7dd 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -9,6 +9,7 @@ var utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; var Popover = ReactBootstrap.Popover; +var SearchAutocomplete = require('./search_autocomplete.jsx'); export default class SearchBar extends React.Component { constructor() { @@ -16,11 +17,13 @@ export default class SearchBar extends React.Component { this.mounted = false; this.onListenerChange = this.onListenerChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleUserFocus = this.handleUserFocus.bind(this); this.handleUserBlur = this.handleUserBlur.bind(this); this.performSearch = this.performSearch.bind(this); this.handleSubmit = this.handleSubmit.bind(this); + this.completeWord = this.completeWord.bind(this); const state = this.getSearchTermStateFromStores(); state.focused = false; @@ -74,11 +77,18 @@ export default class SearchBar extends React.Component { results: null }); } + handleKeyDown(e) { + if (this.refs.autocomplete) { + this.refs.autocomplete.handleKeyDown(e); + } + } handleUserInput(e) { var term = e.target.value; PostStore.storeSearchTerm(term); PostStore.emitSearchTermChange(false); this.setState({searchTerm: term}); + + this.refs.autocomplete.handleInputChange(e.target, term); } handleMouseInput(e) { e.preventDefault(); @@ -97,7 +107,7 @@ export default class SearchBar extends React.Component { this.setState({isSearching: true}); client.search( terms, - function success(data) { + (data) => { this.setState({isSearching: false}); if (utils.isMobile()) { ReactDOM.findDOMNode(this.refs.search).value = ''; @@ -108,11 +118,11 @@ export default class SearchBar extends React.Component { results: data, is_mention_search: isMentionSearch }); - }.bind(this), - function error(err) { + }, + (err) => { this.setState({isSearching: false}); AsyncClient.dispatchError(err, 'search'); - }.bind(this) + } ); } } @@ -120,6 +130,24 @@ export default class SearchBar extends React.Component { e.preventDefault(); this.performSearch(this.state.searchTerm.trim()); } + + completeWord(partialWord, word) { + const textbox = ReactDOM.findDOMNode(this.refs.search); + let text = textbox.value; + + const caret = utils.getCaretPosition(textbox); + const preText = text.substring(0, caret - partialWord.length); + const postText = text.substring(caret); + text = preText + word + postText; + + textbox.value = text; + utils.setCaretPosition(textbox, preText.length + word.length); + + PostStore.storeSearchTerm(text); + PostStore.emitSearchTermChange(false); + this.setState({searchTerm: text}); + } + render() { var isSearching = null; if (this.state.isSearching) { @@ -143,12 +171,13 @@ export default class SearchBar extends React.Component { className='search__clear' onClick={this.clearFocus} > - Cancel + {'Cancel'}
{isSearching} + diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx index 4f0fe3ed0a..774f98a43d 100644 --- a/web/react/components/setting_item_max.jsx +++ b/web/react/components/setting_item_max.jsx @@ -36,7 +36,7 @@ export default class SettingItemMax extends React.Component { if (this.props.width === 'full') { widthClass = 'col-sm-12'; } else { - widthClass = 'col-sm-9 col-sm-offset-3'; + widthClass = 'col-sm-10 col-sm-offset-2'; } return ( diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx index 66568e1c86..4af46c35ac 100644 --- a/web/react/components/settings_sidebar.jsx +++ b/web/react/components/settings_sidebar.jsx @@ -2,6 +2,10 @@ // See License.txt for license information. export default class SettingsSidebar extends React.Component { + componentDidUpdate() { + $('.settings-modal').find('.modal-body').scrollTop(0); + $('.settings-modal').find('.modal-body').perfectScrollbar('update'); + } constructor(props) { super(props); diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index b55373dba4..5c59950209 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -19,6 +19,7 @@ export default class TeamSettingsModal extends React.Component { componentDidMount() { $('body').on('click', '.modal-back', function handleBackClick() { $(this).closest('.modal-dialog').removeClass('display--content'); + $(this).closest('.modal-dialog').find('.settings-table .nav li.active').removeClass('active'); }); $('body').on('click', '.modal-header .close', () => { setTimeout(() => { diff --git a/web/react/components/team_signup_send_invites_page.jsx b/web/react/components/team_signup_send_invites_page.jsx index b42343da0b..7b4db8fae9 100644 --- a/web/react/components/team_signup_send_invites_page.jsx +++ b/web/react/components/team_signup_send_invites_page.jsx @@ -15,11 +15,6 @@ export default class TeamSignupSendInvitesPage extends React.Component { this.state = { emailEnabled: global.window.mm_config.SendEmailNotifications === 'true' }; - - if (!this.state.emailEnabled) { - this.props.state.wizard = 'username'; - this.props.updateParent(this.props.state); - } } submitBack(e) { e.preventDefault(); diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx index 6d333aed67..02d5cab8e7 100644 --- a/web/react/components/team_signup_url_page.jsx +++ b/web/react/components/team_signup_url_page.jsx @@ -54,7 +54,11 @@ export default class TeamSignupUrlPage extends React.Component { if (data) { this.setState({nameError: 'This URL is unavailable. Please try another.'}); } else { - this.props.state.wizard = 'send_invites'; + if (global.window.mm_config.SendEmailNotifications === 'true') { + this.props.state.wizard = 'send_invites'; + } else { + this.props.state.wizard = 'username'; + } this.props.state.team.type = 'O'; this.props.state.team.name = name; diff --git a/web/react/components/team_signup_welcome_page.jsx b/web/react/components/team_signup_welcome_page.jsx index ee325fcaf8..9448413ce6 100644 --- a/web/react/components/team_signup_welcome_page.jsx +++ b/web/react/components/team_signup_welcome_page.jsx @@ -104,21 +104,19 @@ export default class TeamSignupWelcomePage extends React.Component { return (
-

- -

Welcome to:

-

{global.window.mm_config.SiteName}

-

+ +

Welcome to:

+

{global.window.mm_config.SiteName}

Let's set up your new team

-

+

Please confirm your email address:
{this.props.state.team.email}
-

+

Your account will administer the new team site.
You can add other administrators later. diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx index 812169be4c..6b8c09718a 100644 --- a/web/react/components/user_settings/manage_incoming_hooks.jsx +++ b/web/react/components/user_settings/manage_incoming_hooks.jsx @@ -119,24 +119,23 @@ export default class ManageIncomingHooks extends React.Component { hooks.push(

-
-
- {'URL: '}{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id} +
+ {'URL: '} + {Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}
{'Channel: '}{c.display_name}
- + + + +
); } @@ -148,35 +147,38 @@ export default class ManageIncomingHooks extends React.Component { } else if (hooks.length > 0) { displayHooks = hooks; } else { - displayHooks = ; + displayHooks =
{'None'}
; } const existingHooks = ( -
+
- {displayHooks} +
+
+ {displayHooks} +
); return (
{'Create webhook URLs for use in external integrations. Please see '}{'http://mattermost.org/webhooks'} {' to learn more.'} -
-
- -
- - {serverError} -
+ +
+
+ + {serverError} +
+
diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx index f6d6b515b3..6e9b2205d0 100644 --- a/web/react/components/user_settings/manage_outgoing_hooks.jsx +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -6,6 +6,7 @@ var Constants = require('../../utils/constants.jsx'); var ChannelStore = require('../../stores/channel_store.jsx'); var LoadingScreen = require('../loading_screen.jsx'); + export default class ManageOutgoingHooks extends React.Component { constructor() { super(); @@ -180,9 +181,8 @@ export default class ManageOutgoingHooks extends React.Component { hooks.push( +
); }); @@ -218,13 +218,16 @@ export default class ManageOutgoingHooks extends React.Component { } else if (hooks.length > 0) { displayHooks = hooks; } else { - displayHooks = ; + displayHooks =
{'None'}
; } const existingHooks = ( -
+
- {displayHooks} +
+
+ {displayHooks} +
); @@ -234,41 +237,49 @@ export default class ManageOutgoingHooks extends React.Component {
- {'Channel:'} - - {'Only public channels can be used'} -
-
- {'Trigger Words:'} - - {'Comma separated words to trigger on'} -
-
- {'Callback URLs:'} -