From 581b8bf90d53d4909bed3ccc9df5b4a613d8eb81 Mon Sep 17 00:00:00 2001 From: Hunter McMillen Date: Wed, 21 Oct 2015 14:43:50 -0400 Subject: [PATCH 01/65] Production installation instructions for Debian Jessie with Systemd The instructions for Ubuntu work for the most part on Debian installations. The main difference is that Debian does not use Upstart by default, Debian 8 (Jessie) uses Systemd, pre-Jessie Debians use SysV init. This guides shows you how to install Mattermost on Debian under /opt and running as system user named mattermost. It also supplies an init script to be installed in /etc/init.d. If you are using Systemd this will get turned into a Systemd service file at boot-time, otherwise it will just work. --- doc/install/Production-Debian.md | 299 +++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 doc/install/Production-Debian.md diff --git a/doc/install/Production-Debian.md b/doc/install/Production-Debian.md new file mode 100644 index 0000000000..03e5e494f2 --- /dev/null +++ b/doc/install/Production-Debian.md @@ -0,0 +1,299 @@ +# Production Installation on Debian Jessie (x64) + +## Install Debian Jessie (x64) +1. Set up 3 machines with Debian Jessie with 2GB of RAM or more. The servers will be used for the Load Balancer, Mattermost (this must be x64 to use pre-built binaries), and Database. +1. This can also be set up all on a single server for small teams: + * I have a Mattermost instance running on a single Debian Jessie server with 1GB of ram and 30 GB SSD + * This has been working in production for ~20 users without issue. + * The only difference in the below instructions for this method is to do everything on the same server +1. Make sure the system is up to date with the most recent security patches. + * ``` sudo apt-get update``` + * ``` sudo apt-get upgrade``` + +## Set up Database Server +1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.1 +1. Install PostgreSQL 9.3+ (or MySQL 5.6+) + * ``` sudo apt-get install postgresql postgresql-contrib``` +1. PostgreSQL created a user account called `postgres`. You will need to log into that account with: + * ``` sudo -i -u postgres``` +1. You can get a PostgreSQL prompt by typing: + * ``` psql``` +1. Create the Mattermost database by typing: + * ```postgres=# CREATE DATABASE mattermost;``` +1. Create the Mattermost user by typing: + * ```postgres=# CREATE USER mmuser WITH PASSWORD 'mmuser_password';``` +1. Grant the user access to the Mattermost database by typing: + * ```postgres=# GRANT ALL PRIVILEGES ON DATABASE mattermost to mmuser;``` +1. You can exit out of PostgreSQL by typing: + * ```postgre=# \q``` +1. You can exit the postgres account by typing: + * ``` exit``` + +## Set up Mattermost Server +1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.2 +1. Download the latest Mattermost Server by typing: + * ``` wget https://github.com/mattermost/platform/releases/download/v1.1.0/mattermost.tar.gz``` +1. Install Mattermost under /opt + * ``` cd /opt``` + * Unzip the Mattermost Server by typing: + * ``` tar -xvzf mattermost.tar.gz``` +1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/mattermost/data`. + * Create the directory by typing: + * ``` sudo mkdir -p /opt/mattermost/data``` +1. Create a system user and group called mattermost that will run this service + * ``` useradd -r mattermost -U``` + * Set the mattermost account as the directory owner by typing: + * ``` sudo chown -R mattermost:mattermost /opt/mattermost``` + * Add yourself to the mattermost group to ensure you can edit these files: + * ``` sudo usermod -aG mattermost USERNAME``` +1. Configure Mattermost Server by editing the config.json file at /opt/mattermost/config + * ``` cd /opt/mattermost/config``` + * Edit the file by typing: + * ``` vi config.json``` + * replace `DriverName": "mysql"` with `DriverName": "postgres"` + * replace `"DataSource": "mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8"` with `"DataSource": "postgres://mmuser:mmuser_password@10.10.10.1:5432/mattermost?sslmode=disable&connect_timeout=10"` + * Optionally you may continue to edit configuration settings in `config.json` or use the System Console described in a later section to finish the configuration. +1. Test the Mattermost Server + * ``` cd /opt/mattermost/bin``` + * Run the Mattermost Server by typing: + * ``` ./platform``` + * You should see a console log like `Server is listening on :8065` letting you know the service is running. + * Stop the server for now by typing `ctrl-c` +1. Setup Mattermost to use the systemd init daemon which handles supervision of the Mattermost process + * ``` sudo touch /etc/init.d/mattermost``` + * ``` sudo vi /etc/init.d/mattermost``` + * Copy the following lines into `/etc/init.d/mattermost` +``` +#! /bin/sh +### BEGIN INIT INFO +# Provides: mattermost +# Required-Start: $network $syslog +# Required-Stop: $network $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Mattermost Group Chat +# Description: Mattermost: An open-source Slack +### END INIT INFO + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="Mattermost" +NAME=mattermost +MATTERMOST_ROOT=/opt/mattermost +MATTERMOST_GROUP=mattermost +MATTERMOST_USER=mattermost +DAEMON="$MATTERMOST_ROOT/bin/platform" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +. /lib/lsb/init-functions + +do_start() { + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + start-stop-daemon --start --quiet \ + --chuid $MATTERMOST_USER:$MATTERMOST_GROUP --chdir $MATTERMOST_ROOT --background \ + --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ + || return 1 + start-stop-daemon --start --quiet \ + --chuid $MATTERMOST_USER:$MATTERMOST_GROUP --chdir $MATTERMOST_ROOT --background \ + --make-pidfile --pidfile $PIDFILE --exec $DAEMON \ + || return 2 +} + +# +# Function that stops the daemon/service +# +do_stop() { + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 \ + --pidfile $PIDFILE --exec $DAEMON + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + # Wait for children to finish too if this is a daemon that forks + # and if the daemon is only ever run from this initscript. + # If the above conditions are not satisfied then add some other code + # that waits for the process to drop all resources that could be + # needed by services started subsequently. A last resort is to + # sleep for some time. + start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 \ + --exec $DAEMON + [ "$?" = 2 ] && return 2 + # Many daemons don't delete their pidfiles when they exit. + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in +start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; +stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; +status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; +restart|force-reload) + # + # If the "reload" option is implemented then remove the + # 'force-reload' alias + # + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; +*) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +exit 0 +``` + * Make sure that /etc/init.d/mattermost is executable + * ``` chmod +x /etc/init.d/mattermost``` +1. On reboot, systemd will generate a unit file from the headers in this init script and install it in `/run/systemd/generator.late/` + +## Set up Nginx Server +1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.3 +1. We use Nginx for proxying request to the Mattermost Server. The main benefits are: + * SSL termination + * http to https redirect + * Port mapping :80 to :8065 + * Standard request logs +1. Install Nginx on Debian with + * ``` sudo apt-get install nginx``` +1. Verify Nginx is running + * ``` curl http://10.10.10.3``` + * You should see a *Welcome to nginx!* page +1. You can manage Nginx with the following commands + * ``` sudo service nginx stop``` + * ``` sudo service nginx start``` + * ``` sudo service nginx restart``` +1. Map a FQDN (fully qualified domain name) like **mattermost.example.com** to point to the Nginx server. +1. Configure Nginx to proxy connections from the internet to the Mattermost Server + * Create a configuration for Mattermost + * ``` sudo touch /etc/nginx/sites-available/mattermost``` + * Below is a sample configuration with the minimum settings required to configure Mattermost + ``` + server { + server_name mattermost.example.com; + location / { + client_max_body_size 50M; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Frame-Options SAMEORIGIN; + proxy_pass http://localhost:8065; + } + } +``` + * Remove the existing file with + * ``` sudo rm /etc/nginx/sites-enabled/default``` + * Link the mattermost config by typing: + * ```sudo ln -s /etc/nginx/sites-available/mattermost /etc/nginx/sites-enabled/mattermost``` + * Restart Nginx by typing: + * ``` sudo service nginx restart``` + * Verify you can see Mattermost thru the proxy by typing: + * ``` curl http://localhost``` + * You should see a page titles *Mattermost - Signup* + +## Set up Nginx with SSL (Recommended) +1. You will need a SSL cert from a certificate authority. +1. For simplicity we will generate a test certificate. + * ``` mkdir ~/cert``` + * ``` cd ~/cert``` + * ``` sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout mattermost.key -out mattermost.crt``` + * Input the following info +``` + Country Name (2 letter code) [AU]:US + State or Province Name (full name) [Some-State]:California + Locality Name (eg, city) []:Palo Alto + Organization Name (eg, company) [Internet Widgits Pty Ltd]:Example LLC + Organizational Unit Name (eg, section) []: + Common Name (e.g. server FQDN or YOUR name) []:mattermost.example.com + Email Address []:admin@mattermost.example.com +``` +1. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines + * +``` + server { + listen 80; + server_name mattermost.example.com; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl; + server_name mattermost.example.com; + + ssl on; + ssl_certificate /home/mattermost/cert/mattermost.crt; + ssl_certificate_key /home/mattermost/cert/mattermost.key; + ssl_session_timeout 5m; + ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES"; + ssl_prefer_server_ciphers on; + + # add to location / above + location / { + gzip off; + 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. + * Set *Send Email Notifications* to true + * Set *Require Email Verification* to true + * Set *Feedback Name* to `No-Reply` + * Set *Feedback Email* to `mattermost@example.com` + * Set *SMTP Username* to `AFIADTOVDKDLGERR` + * Set *SMTP Password* to `DFKJoiweklsjdflkjOIGHLSDFJewiskdjf` + * Set *SMTP Server* to `email-smtp.us-east-1.amazonaws.com` + * Set *SMTP Port* to `465` + * Set *Connection Security* to `TLS` + * Save the Settings +1. Update File Settings + * Change *Local Directory Location* from `./data/` to `/mattermost/data` +1. Update Log Settings. + * Set *Log to The Console* to false +1. Update Rate Limit Settings. + * Set *Vary By Remote Address* to false + * Set *Vary By HTTP Header* to X-Real-IP +1. Feel free to modify other settings. +1. Restart the Mattermost Service by typing: + * ``` sudo restart mattermost``` From 4a1f6ad2a972bb0f30414db3dc1899d88a01d29b Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 20 Oct 2015 16:50:55 -0400 Subject: [PATCH 02/65] Added an autocomplete dropdown to the search bar --- web/react/components/search_autocomplete.jsx | 139 +++++++++++++++++++ web/react/components/search_bar.jsx | 27 ++++ web/react/stores/user_store.jsx | 12 ++ 3 files changed, 178 insertions(+) create mode 100644 web/react/components/search_autocomplete.jsx diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx new file mode 100644 index 0000000000..284b475c1c --- /dev/null +++ b/web/react/components/search_autocomplete.jsx @@ -0,0 +1,139 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const ChannelStore = require('../stores/channel_store.jsx'); +const UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); + +const patterns = { + channels: /\b(?:in|channel):\s*(\S*)$/i, + users: /\bfrom:\s*(\S*)$/i +}; + +export default class SearchAutocomplete extends React.Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleDocumentClick = this.handleDocumentClick.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + + this.state = { + show: false, + mode: '', + filter: '' + }; + } + + componentDidMount() { + $(document).on('click', this.handleDocumentClick); + } + + componentWillUnmount() { + $(document).off('click', this.handleDocumentClick); + } + + handleClick(value) { + this.props.completeWord(this.state.filter, value); + + this.setState({ + show: false, + mode: '', + filter: '' + }); + } + + handleDocumentClick(e) { + const container = $(ReactDOM.findDOMNode(this.refs.container)); + + if (!(container.is(e.target) || container.has(e.target).length > 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 pattern in patterns) { + const result = patterns[pattern].exec(preText); + + if (result) { + mode = pattern; + filter = result[1]; + break; + } + } + + this.setState({ + mode, + filter, + show: mode || filter + }); + } + + render() { + if (!this.state.show) { + return null; + } + + let suggestions = []; + + if (this.state.mode === 'channels') { + let channels = ChannelStore.getAll(); + + if (this.state.filter) { + channels = channels.filter((channel) => channel.name.startsWith(this.state.filter)); + } + + suggestions = channels.map((channel) => { + return ( +
+ {channel.name} +
+ ); + }); + } else if (this.state.mode === 'users') { + let users = UserStore.getActiveOnlyProfileList(); + + if (this.state.filter) { + users = users.filter((user) => user.username.startsWith(this.state.filter)); + } + + suggestions = users.map((user) => { + return ( +
+ {user.username} +
+ ); + }); + } + + if (suggestions.length === 0) { + return null; + } + + 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..509ca94e9d 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() { @@ -21,6 +22,7 @@ export default class SearchBar extends React.Component { 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; @@ -79,6 +81,8 @@ export default class SearchBar extends React.Component { PostStore.storeSearchTerm(term); PostStore.emitSearchTermChange(false); this.setState({searchTerm: term}); + + this.refs.autocomplete.handleInputChange(e.target, term); } handleMouseInput(e) { e.preventDefault(); @@ -120,6 +124,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) { @@ -149,6 +171,7 @@ export default class SearchBar extends React.Component { role='form' className='search__form relative-div' onSubmit={this.handleSubmit} + style={{"overflow": "visible"}} > {isSearching} + Date: Tue, 20 Oct 2015 17:31:20 -0400 Subject: [PATCH 03/65] Added styling to search autocomplete --- web/react/components/search_autocomplete.jsx | 12 +++++++- web/sass-files/sass/partials/_search.scss | 30 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 284b475c1c..0229b07fdd 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -90,11 +90,14 @@ export default class SearchAutocomplete extends React.Component { channels = channels.filter((channel) => channel.name.startsWith(this.state.filter)); } + channels.sort((a, b) => a.name.localeCompare(b.name)); + suggestions = channels.map((channel) => { return (
{channel.name}
@@ -107,12 +110,19 @@ export default class SearchAutocomplete extends React.Component { users = users.filter((user) => user.username.startsWith(this.state.filter)); } + users.sort((a, b) => a.username.localeCompare(b.username)); + suggestions = users.map((user) => { return (
+ {suggestions}
diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss index 2f15a445f1..d7287295b5 100644 --- a/web/sass-files/sass/partials/_search.scss +++ b/web/sass-files/sass/partials/_search.scss @@ -109,3 +109,33 @@ .search-highlight { background-color: #FFF2BB; } + +.search-autocomplete { + background-color: #fff; + border: $border-gray; + line-height: 36px; + overflow-x: hidden; + overflow-y: scroll; + position: absolute; + text-align: left; + width: 100%; + z-index: 100; + @extend %popover-box-shadow; +} + +.search-autocomplete__channel { + height: 36px; + padding: 0px 6px; +} + +.search-autocomplete__user { + height: 36px; + padding: 0px; + + .profile-img { + height: 32px; + margin-right: 6px; + width: 32px; + @include border-radius(16px); + } +} From a5a2826700b1fc6b19ba38698cfa703f58476bc6 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Wed, 21 Oct 2015 11:03:50 -0400 Subject: [PATCH 04/65] Added keyboard selection to search autocomplete --- web/react/components/search_autocomplete.jsx | 180 ++++++++++++++----- web/react/components/search_bar.jsx | 19 +- web/react/stores/user_store.jsx | 4 +- web/react/utils/constants.jsx | 3 +- web/sass-files/sass/partials/_search.scss | 10 ++ 5 files changed, 168 insertions(+), 48 deletions(-) diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 0229b07fdd..03c7b894cf 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -2,13 +2,14 @@ // See License.txt for license information. const ChannelStore = require('../stores/channel_store.jsx'); +const KeyCodes = require('../utils/constants.jsx').KeyCodes; const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); -const patterns = { - channels: /\b(?:in|channel):\s*(\S*)$/i, - users: /\bfrom:\s*(\S*)$/i -}; +const patterns = new Map([ + ['channels', /\b(?:in|channel):\s*(\S*)$/i], + ['users', /\bfrom:\s*(\S*)$/i] +]); export default class SearchAutocomplete extends React.Component { constructor(props) { @@ -17,11 +18,17 @@ export default class SearchAutocomplete extends React.Component { this.handleClick = this.handleClick.bind(this); this.handleDocumentClick = this.handleDocumentClick.bind(this); this.handleInputChange = this.handleInputChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + + this.completeWord = this.completeWord.bind(this); + this.updateSuggestions = this.updateSuggestions.bind(this); this.state = { show: false, mode: '', - filter: '' + filter: '', + selection: 0, + suggestions: new Map() }; } @@ -34,13 +41,7 @@ export default class SearchAutocomplete extends React.Component { } handleClick(value) { - this.props.completeWord(this.state.filter, value); - - this.setState({ - show: false, - mode: '', - filter: '' - }); + this.completeWord(value); } handleDocumentClick(e) { @@ -59,16 +60,20 @@ export default class SearchAutocomplete extends React.Component { let mode = ''; let filter = ''; - for (const pattern in patterns) { - const result = patterns[pattern].exec(preText); + for (const [modeForPattern, pattern] of patterns) { + const result = pattern.exec(preText); if (result) { - mode = pattern; + mode = modeForPattern; filter = result[1]; break; } } + if (mode !== this.state.mode || filter !== this.state.filter) { + this.updateSuggestions(mode, filter); + } + this.setState({ mode, filter, @@ -76,48 +81,147 @@ export default class SearchAutocomplete extends React.Component { }); } + 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) { + if (!this.state.show || this.state.suggestions.length === 0) { return null; } let suggestions = []; if (this.state.mode === 'channels') { - let channels = ChannelStore.getAll(); + suggestions = this.state.suggestions.map((channel, index) => { + let className = 'search-autocomplete__channel'; + if (this.state.selection === index) { + className += ' selected'; + } - if (this.state.filter) { - channels = channels.filter((channel) => channel.name.startsWith(this.state.filter)); - } - - channels.sort((a, b) => a.name.localeCompare(b.name)); - - suggestions = channels.map((channel) => { return (
{channel.name}
); }); } else if (this.state.mode === 'users') { - let users = UserStore.getActiveOnlyProfileList(); + suggestions = this.state.suggestions.map((user, index) => { + let className = 'search-autocomplete__user'; + if (this.state.selection === index) { + className += ' selected'; + } - if (this.state.filter) { - users = users.filter((user) => user.username.startsWith(this.state.filter)); - } - - users.sort((a, b) => a.username.localeCompare(b.username)); - - suggestions = users.map((user) => { return (
{ this.setState({isSearching: false}); if (utils.isMobile()) { ReactDOM.findDOMNode(this.refs.search).value = ''; @@ -112,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) + } ); } } @@ -165,13 +171,13 @@ export default class SearchBar extends React.Component { className='search__clear' onClick={this.clearFocus} > - Cancel + {'Cancel'}
{isSearching} diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index e3e1944ceb..ce80c5ec9f 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -221,7 +221,9 @@ class UserStoreClass extends EventEmitter { const profiles = []; for (const id in profileMap) { - profiles.push(profileMap[id]); + if (profileMap.hasOwnProperty(id)) { + profiles.push(profileMap[id]); + } } return profiles; diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 7d2626fc13..72773bf057 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -311,6 +311,7 @@ module.exports = { RIGHT: 39, BACKSPACE: 8, ENTER: 13, - ESCAPE: 27 + ESCAPE: 27, + SPACE: 32 } }; diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss index d7287295b5..ce35638854 100644 --- a/web/sass-files/sass/partials/_search.scss +++ b/web/sass-files/sass/partials/_search.scss @@ -124,11 +124,17 @@ } .search-autocomplete__channel { + cursor: pointer; height: 36px; padding: 0px 6px; + + &.selected { + background-color:rgba(51, 51, 51, 0.15); + } } .search-autocomplete__user { + cursor: pointer; height: 36px; padding: 0px; @@ -138,4 +144,8 @@ width: 32px; @include border-radius(16px); } + + &.selected { + background-color:rgba(51, 51, 51, 0.15); + } } From 90b94f8fa9ae984aea8146e48e39adf066bf3d1b Mon Sep 17 00:00:00 2001 From: it33 Date: Fri, 23 Oct 2015 13:51:58 -0700 Subject: [PATCH 05/65] PLT-629 - Update the reset password notification email PLT-629 - Update the reset password notification email --- api/templates/password_change_body.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.

From 2383d5dd37d5ebf28c2576fd495a8a7f02f78901 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Fri, 23 Oct 2015 17:28:02 -0400 Subject: [PATCH 06/65] Changed post searching to allow searching by multiple users/channels --- api/post.go | 42 ++++------------ api/post_test.go | 37 +++++++++++++- model/post_list.go | 9 ++++ model/post_list_test.go | 34 +++++++++++++ model/search_params.go | 99 ++++++++++++++++++++++--------------- model/search_params_test.go | 17 +++++-- 6 files changed, 161 insertions(+), 77 deletions(-) diff --git a/api/post.go b/api/post.go index c5bcd4f5aa..e359f2df40 100644 --- a/api/post.go +++ b/api/post.go @@ -820,45 +820,23 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) { return } - plainSearchParams, hashtagSearchParams := model.ParseSearchParams(terms) + paramsList := model.ParseSearchParams(terms) + channels := []store.StoreChannel{} - var hchan store.StoreChannel - if hashtagSearchParams != nil { - hchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, hashtagSearchParams) + for _, params := range paramsList { + channels = append(channels, Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, params)) } - var pchan store.StoreChannel - if plainSearchParams != nil { - pchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, plainSearchParams) - } - - mainList := &model.PostList{} - if hchan != nil { - if result := <-hchan; result.Err != nil { + posts := &model.PostList{} + for _, channel := range channels { + if result := <-channel; result.Err != nil { c.Err = result.Err return } else { - mainList = result.Data.(*model.PostList) + data := result.Data.(*model.PostList) + posts.Extend(data) } } - plainList := &model.PostList{} - if pchan != nil { - if result := <-pchan; result.Err != nil { - c.Err = result.Err - return - } else { - plainList = result.Data.(*model.PostList) - } - } - - for _, postId := range plainList.Order { - if _, ok := mainList.Posts[postId]; !ok { - mainList.AddPost(plainList.Posts[postId]) - mainList.AddOrder(postId) - } - - } - - w.Write([]byte(mainList.ToJson())) + w.Write([]byte(posts.ToJson())) } diff --git a/api/post_test.go b/api/post_test.go index ac9d5668b2..3df622d842 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -427,12 +427,18 @@ func TestSearchPostsInChannel(t *testing.T) { channel2 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) + channel3 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel3 = Client.Must(Client.CreateChannel(channel3)).Data.(*model.Channel) + post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"} post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) post3 := &model.Post{ChannelId: channel2.Id, Message: "other message with no return"} post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post) + post4 := &model.Post{ChannelId: channel3.Id, Message: "other message with no return"} + post4 = Client.Must(Client.CreatePost(post4)).Data.(*model.Post) + if result := Client.Must(Client.SearchPosts("channel:")).Data.(*model.PostList); len(result.Order) != 0 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } @@ -476,6 +482,10 @@ func TestSearchPostsInChannel(t *testing.T) { if result := Client.Must(Client.SearchPosts("sgtitlereview channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } + + if result := Client.Must(Client.SearchPosts("channel: " + channel2.Name + " channel: " + channel3.Name)).Data.(*model.PostList); len(result.Order) != 3 { + t.Fatalf("wrong number of posts returned :) %v :) %v", result.Posts, result.Order) + } } func TestSearchPostsFromUser(t *testing.T) { @@ -510,11 +520,12 @@ func TestSearchPostsFromUser(t *testing.T) { post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"} post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) + // includes "X has joined the channel" messages for both user2 and user3 + if result := Client.Must(Client.SearchPosts("from: " + user1.Username)).Data.(*model.PostList); len(result.Order) != 1 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } - // note that this includes the "User2 has joined the channel" system messages if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } @@ -526,6 +537,30 @@ func TestSearchPostsFromUser(t *testing.T) { if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " in:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } + + user3 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user3.Id)) + + Client.LoginByEmail(team.Name, user3.Email, "pwd") + Client.Must(Client.JoinChannel(channel1.Id)) + Client.Must(Client.JoinChannel(channel2.Id)) + + if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 { + t.Fatalf("wrong number of posts returned %v", len(result.Order)) + } + + if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username)).Data.(*model.PostList); len(result.Order) != 5 { + t.Fatalf("wrong number of posts returned %v", len(result.Order)) + } + + if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name)).Data.(*model.PostList); len(result.Order) != 3 { + t.Fatalf("wrong number of posts returned %v", len(result.Order)) + } + + if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name + " joined")).Data.(*model.PostList); len(result.Order) != 2 { + t.Fatalf("wrong number of posts returned %v", len(result.Order)) + } } func TestGetPostsCache(t *testing.T) { diff --git a/model/post_list.go b/model/post_list.go index 862673ef35..4c0f5408e3 100644 --- a/model/post_list.go +++ b/model/post_list.go @@ -54,6 +54,15 @@ func (o *PostList) AddPost(post *Post) { o.Posts[post.Id] = post } +func (o *PostList) Extend(other *PostList) { + for _, postId := range other.Order { + if _, ok := o.Posts[postId]; !ok { + o.AddPost(other.Posts[postId]) + o.AddOrder(postId) + } + } +} + func (o *PostList) Etag() string { id := "0" diff --git a/model/post_list_test.go b/model/post_list_test.go index 8a34327ce4..9ce6447e10 100644 --- a/model/post_list_test.go +++ b/model/post_list_test.go @@ -34,3 +34,37 @@ func TestPostListJson(t *testing.T) { t.Fatal("failed to serialize") } } + +func TestPostListExtend(t *testing.T) { + l1 := PostList{} + + p1 := &Post{Id: NewId(), Message: NewId()} + l1.AddPost(p1) + l1.AddOrder(p1.Id) + + p2 := &Post{Id: NewId(), Message: NewId()} + l1.AddPost(p2) + l1.AddOrder(p2.Id) + + l2 := PostList{} + + p3 := &Post{Id: NewId(), Message: NewId()} + l2.AddPost(p3) + l2.AddOrder(p3.Id) + + l2.Extend(&l1) + + if len(l1.Posts) != 2 || len(l1.Order) != 2 { + t.Fatal("extending l2 changed l1") + } else if len(l2.Posts) != 3 { + t.Fatal("failed to extend posts l2") + } else if l2.Order[0] != p3.Id || l2.Order[1] != p1.Id || l2.Order[2] != p2.Id { + t.Fatal("failed to extend order of l2") + } + + if len(l1.Posts) != 2 || len(l1.Order) != 2 { + t.Fatal("extending l2 again changed l1") + } else if len(l2.Posts) != 3 || len(l2.Order) != 3 { + t.Fatal("extending l2 again changed l2") + } +} diff --git a/model/search_params.go b/model/search_params.go index 7eeeed10f2..6b665f5a03 100644 --- a/model/search_params.go +++ b/model/search_params.go @@ -31,9 +31,9 @@ func splitWords(text string) []string { return words } -func parseSearchFlags(input []string) ([]string, map[string]string) { +func parseSearchFlags(input []string) ([]string, [][2]string) { words := []string{} - flags := make(map[string]string) + flags := [][2]string{} skipNextWord := false for i, word := range input { @@ -52,10 +52,10 @@ func parseSearchFlags(input []string) ([]string, map[string]string) { // check for case insensitive equality if strings.EqualFold(flag, searchFlag) { if value != "" { - flags[searchFlag] = value + flags = append(flags, [2]string{searchFlag, value}) isFlag = true } else if i < len(input)-1 { - flags[searchFlag] = input[i+1] + flags = append(flags, [2]string{searchFlag, input[i+1]}) skipNextWord = true isFlag = true } @@ -75,56 +75,77 @@ func parseSearchFlags(input []string) ([]string, map[string]string) { return words, flags } -func ParseSearchParams(text string) (*SearchParams, *SearchParams) { +func ParseSearchParams(text string) []*SearchParams { words, flags := parseSearchFlags(splitWords(text)) - hashtagTerms := []string{} - plainTerms := []string{} + hashtagTermList := []string{} + plainTermList := []string{} for _, word := range words { if validHashtag.MatchString(word) { - hashtagTerms = append(hashtagTerms, word) + hashtagTermList = append(hashtagTermList, word) } else { - plainTerms = append(plainTerms, word) + plainTermList = append(plainTermList, word) } } - inChannel := flags["channel"] - if inChannel == "" { - inChannel = flags["in"] - } + hashtagTerms := strings.Join(hashtagTermList, " ") + plainTerms := strings.Join(plainTermList, " ") - fromUser := flags["from"] + inChannels := []string{} + fromUsers := []string{} - var plainParams *SearchParams - if len(plainTerms) > 0 { - plainParams = &SearchParams{ - Terms: strings.Join(plainTerms, " "), - IsHashtag: false, - InChannel: inChannel, - FromUser: fromUser, + for _, flagPair := range flags { + flag := flagPair[0] + value := flagPair[1] + + if flag == "in" || flag == "channel" { + inChannels = append(inChannels, value) + } else if flag == "from" { + fromUsers = append(fromUsers, value) } } - var hashtagParams *SearchParams - if len(hashtagTerms) > 0 { - hashtagParams = &SearchParams{ - Terms: strings.Join(hashtagTerms, " "), - IsHashtag: true, - InChannel: inChannel, - FromUser: fromUser, + if len(inChannels) == 0 { + inChannels = append(inChannels, "") + } + if len(fromUsers) == 0 { + fromUsers = append(fromUsers, "") + } + + paramsList := []*SearchParams{} + + for _, inChannel := range inChannels { + for _, fromUser := range fromUsers { + if len(plainTerms) > 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: plainTerms, + IsHashtag: false, + InChannel: inChannel, + FromUser: fromUser, + }) + } + + if len(hashtagTerms) > 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: hashtagTerms, + IsHashtag: true, + InChannel: inChannel, + FromUser: fromUser, + }) + } + + // special case for when no terms are specified but we still have a filter + if len(plainTerms) == 0 && len(hashtagTerms) == 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: "", + IsHashtag: true, + InChannel: inChannel, + FromUser: fromUser, + }) + } } } - // special case for when no terms are specified but we still have a filter - if plainParams == nil && hashtagParams == nil && (inChannel != "" || fromUser != "") { - plainParams = &SearchParams{ - Terms: "", - IsHashtag: false, - InChannel: inChannel, - FromUser: fromUser, - } - } - - return plainParams, hashtagParams + return paramsList } diff --git a/model/search_params_test.go b/model/search_params_test.go index 2eba20f4c4..e03e82c5a1 100644 --- a/model/search_params_test.go +++ b/model/search_params_test.go @@ -28,25 +28,25 @@ func TestParseSearchFlags(t *testing.T) { if words, flags := parseSearchFlags(splitWords("apple banana from:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { t.Fatalf("got incorrect words %v", words) - } else if len(flags) != 1 || flags["from"] != "chan" { + } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" { t.Fatalf("got incorrect flags %v", flags) } if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { t.Fatalf("got incorrect words %v", words) - } else if len(flags) != 1 || flags["from"] != "chan" { + } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" { t.Fatalf("got incorrect flags %v", flags) } if words, flags := parseSearchFlags(splitWords("apple banana in: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { t.Fatalf("got incorrect words %v", words) - } else if len(flags) != 1 || flags["in"] != "chan" { + } else if len(flags) != 1 || flags[0][0] != "in" || flags[0][1] != "chan" { t.Fatalf("got incorrect flags %v", flags) } if words, flags := parseSearchFlags(splitWords("apple banana channel:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { t.Fatalf("got incorrect words %v", words) - } else if len(flags) != 1 || flags["channel"] != "chan" { + } else if len(flags) != 1 || flags[0][0] != "channel" || flags[0][1] != "chan" { t.Fatalf("got incorrect flags %v", flags) } @@ -64,7 +64,14 @@ func TestParseSearchFlags(t *testing.T) { if words, flags := parseSearchFlags(splitWords("channel: first in: second from:")); len(words) != 1 || words[0] != "from:" { t.Fatalf("got incorrect words %v", words) - } else if len(flags) != 2 || flags["channel"] != "first" || flags["in"] != "second" { + } else if len(flags) != 2 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "in" || flags[1][1] != "second" { + t.Fatalf("got incorrect flags %v", flags) + } + + if words, flags := parseSearchFlags(splitWords("channel: first channel: second from: third from: fourth")); len(words) != 0 { + t.Fatalf("got incorrect words %v", words) + } else if len(flags) != 4 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "channel" || flags[1][1] != "second" || + flags[2][0] != "from" || flags[2][1] != "third" || flags[3][0] != "from" || flags[3][1] != "fourth" { t.Fatalf("got incorrect flags %v", flags) } } From d9fbf36432c10f45cf3eedc5e0aaca9a97c464fb Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Fri, 23 Oct 2015 16:58:45 -0700 Subject: [PATCH 07/65] Adding hotmail settings --- doc/install/SMTP-Email-Setup.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/install/SMTP-Email-Setup.md b/doc/install/SMTP-Email-Setup.md index b958a6a964..513188be3f 100644 --- a/doc/install/SMTP-Email-Setup.md +++ b/doc/install/SMTP-Email-Setup.md @@ -57,7 +57,11 @@ To enable email, configure an SMTP email service as follows: * Information needed ##### Hotmail -* Information needed +* Set **SMTP Username** to **@hotmail.com)** +* Set **SMTP Password** to **** +* Set **SMTP Server** to **smtp-mail.outlook.com** +* Set **SMTP Port** to **587** +* Set **Connection Security** to **STARTTLS** ### Troubleshooting SMTP From 32810475c92ba0284ec9f4c866e28fd12bae54ba Mon Sep 17 00:00:00 2001 From: it33 Date: Fri, 23 Oct 2015 16:59:21 -0700 Subject: [PATCH 08/65] Adding notes on SPF and DKIM --- doc/install/SMTP-Email-Setup.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/install/SMTP-Email-Setup.md b/doc/install/SMTP-Email-Setup.md index b958a6a964..ad7f51bda9 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** @@ -91,4 +92,4 @@ Connected to mail.example.com. 250-STARTTLS 250-PIPELINING 250 8BITMIME -``` \ No newline at end of file +``` From 750ffb8780a98dcb2f73f307b60c5e4e993153e6 Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Fri, 23 Oct 2015 16:59:57 -0700 Subject: [PATCH 09/65] Adding hotmail settings --- doc/install/SMTP-Email-Setup.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/install/SMTP-Email-Setup.md b/doc/install/SMTP-Email-Setup.md index 513188be3f..b35af931d1 100644 --- a/doc/install/SMTP-Email-Setup.md +++ b/doc/install/SMTP-Email-Setup.md @@ -57,8 +57,8 @@ To enable email, configure an SMTP email service as follows: * Information needed ##### Hotmail -* Set **SMTP Username** to **@hotmail.com)** -* Set **SMTP Password** to **** +* 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** From 3d8ec7a55c784e0e2886df07b444974817201298 Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Fri, 23 Oct 2015 17:00:13 -0700 Subject: [PATCH 10/65] Adding hotmail settings --- doc/install/SMTP-Email-Setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/SMTP-Email-Setup.md b/doc/install/SMTP-Email-Setup.md index b35af931d1..04c1dd620e 100644 --- a/doc/install/SMTP-Email-Setup.md +++ b/doc/install/SMTP-Email-Setup.md @@ -57,7 +57,7 @@ To enable email, configure an SMTP email service as follows: * Information needed ##### Hotmail -* Set **SMTP Username** to **your_email@hotmail.com)** +* 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** From cd958a7ec76c04d9615b58f81ef910bd0ed22e23 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Fri, 23 Oct 2015 17:13:10 -0700 Subject: [PATCH 11/65] Fixed react warning for the search bar tooltip --- web/react/components/search_bar.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 3932807d0c..e1d36ad7dd 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -198,6 +198,7 @@ export default class SearchBar extends React.Component { completeWord={this.completeWord} /> From 129eba3507c04f63d2fab6bf0cdd314ab616ee48 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Fri, 23 Oct 2015 17:30:58 -0700 Subject: [PATCH 12/65] Fixed react warnings in channel_notifications.jsx --- .../components/channel_notifications.jsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) 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}

From d6d5f9310ef8da7d3aaec1a10ea9387455e8eae4 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Fri, 23 Oct 2015 17:40:03 -0700 Subject: [PATCH 13/65] Fixed react warning in more_direct_channels.jsx --- web/react/components/more_direct_channels.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ( - + Date: Fri, 23 Oct 2015 18:07:54 -0700 Subject: [PATCH 14/65] Fixed various React warnings during the team signup process --- .../team_signup_send_invites_page.jsx | 5 ----- web/react/components/team_signup_url_page.jsx | 6 +++++- .../components/team_signup_welcome_page.jsx | 18 ++++++++---------- 3 files changed, 13 insertions(+), 16 deletions(-) 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. From dc439fd7e84d54a95f6c0ac2b51a21e634777436 Mon Sep 17 00:00:00 2001 From: it33 Date: Sat, 24 Oct 2015 10:55:07 -0700 Subject: [PATCH 15/65] Create CONTRIBUTING.md --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CONTRIBUTING.md 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) From 113741243bee612b9e65530e1827a0891d96474c Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sat, 24 Oct 2015 03:25:26 +0200 Subject: [PATCH 16/65] highlight code in markdown blocks --- web/react/package.json | 1 + web/react/utils/constants.jsx | 23 ++++++++++ web/react/utils/markdown.jsx | 61 +++++++++++++++++++++++++ web/sass-files/sass/partials/_post.scss | 16 +++++++ web/static/css/highlight | 1 + web/templates/head.html | 1 + 6 files changed, 103 insertions(+) create mode 120000 web/static/css/highlight diff --git a/web/react/package.json b/web/react/package.json index e6a6623752..9af6f58800 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -6,6 +6,7 @@ "autolinker": "0.18.1", "babel-runtime": "5.8.24", "flux": "2.1.1", + "highlight.js": "^8.9.1", "keymirror": "0.1.1", "marked": "0.3.5", "object-assign": "3.0.0", diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 72773bf057..1a1d7f39f0 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -313,5 +313,28 @@ module.exports = { ENTER: 13, ESCAPE: 27, SPACE: 32 + }, + HighlightedLanguages: { + diff: 'Diff', + apache: 'Apache', + makefile: 'Makefile', + http: 'HTTP', + json: 'JSON', + markdown: 'Markdown', + javascript: 'JavaScript', + css: 'CSS', + nginx: 'nginx', + objectivec: 'Objective-C', + python: 'Python', + xml: 'XML', + perl: 'Perl', + bash: 'Bash', + php: 'PHP', + coffeescript: 'CoffeeScript', + cs: 'C#', + cpp: 'C++', + sql: 'SQL', + go: 'Go', + ruby: 'Ruby' } }; diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 7a4e70054c..8cfcfdefd7 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -6,6 +6,32 @@ const Utils = require('./utils.jsx'); const marked = require('marked'); +const highlightJs = require('highlight.js/lib/highlight.js'); +const highlightJsDiff = require('highlight.js/lib/languages/diff.js'); +const highlightJsApache = require('highlight.js/lib/languages/apache.js'); +const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js'); +const highlightJsHttp = require('highlight.js/lib/languages/http.js'); +const highlightJsJson = require('highlight.js/lib/languages/json.js'); +const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js'); +const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js'); +const highlightJsCss = require('highlight.js/lib/languages/css.js'); +const highlightJsNginx = require('highlight.js/lib/languages/nginx.js'); +const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js'); +const highlightJsPython = require('highlight.js/lib/languages/python.js'); +const highlightJsXml = require('highlight.js/lib/languages/xml.js'); +const highlightJsPerl = require('highlight.js/lib/languages/perl.js'); +const highlightJsBash = require('highlight.js/lib/languages/bash.js'); +const highlightJsPhp = require('highlight.js/lib/languages/php.js'); +const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js'); +const highlightJsCs = require('highlight.js/lib/languages/cs.js'); +const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); +const highlightJsSql = require('highlight.js/lib/languages/sql.js'); +const highlightJsGo = require('highlight.js/lib/languages/go.js'); +const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); + +const Constants = require('../utils/constants.jsx'); +const HighlightedLanguages = Constants.HighlightedLanguages; + export class MattermostMarkdownRenderer extends marked.Renderer { constructor(options, formattingOptions = {}) { super(options); @@ -15,6 +41,41 @@ export class MattermostMarkdownRenderer extends marked.Renderer { this.text = this.text.bind(this); this.formattingOptions = formattingOptions; + + highlightJs.registerLanguage('diff', highlightJsDiff); + highlightJs.registerLanguage('apache', highlightJsApache); + highlightJs.registerLanguage('makefile', highlightJsMakefile); + highlightJs.registerLanguage('http', highlightJsHttp); + highlightJs.registerLanguage('json', highlightJsJson); + highlightJs.registerLanguage('markdown', highlightJsMarkdown); + highlightJs.registerLanguage('javascript', highlightJsJavascript); + highlightJs.registerLanguage('css', highlightJsCss); + highlightJs.registerLanguage('nginx', highlightJsNginx); + highlightJs.registerLanguage('objectivec', highlightJsObjectivec); + highlightJs.registerLanguage('python', highlightJsPython); + highlightJs.registerLanguage('xml', highlightJsXml); + highlightJs.registerLanguage('perl', highlightJsPerl); + highlightJs.registerLanguage('bash', highlightJsBash); + highlightJs.registerLanguage('php', highlightJsPhp); + highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); + highlightJs.registerLanguage('cs', highlightJsCs); + highlightJs.registerLanguage('cpp', highlightJsCpp); + highlightJs.registerLanguage('sql', highlightJsSql); + highlightJs.registerLanguage('go', highlightJsGo); + highlightJs.registerLanguage('ruby', highlightJsRuby); + } + + code(code, language) { + if (!language || highlightJs.listLanguages().indexOf(language) < 0) { + let parsed = super.code(code, language); + return '' + parsed.substr(11, parsed.length - 17); + } + + let parsed = highlightJs.highlight(language, code); + return '

' + + '' + HighlightedLanguages[language] + '' + + '' + parsed.value + '' + + '
'; } br() { diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index f5fc1631f3..3fac1fed9d 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -466,6 +466,22 @@ body.ios { white-space: nowrap; cursor: pointer; } + .post-body--code { + font-size: .97em; + position:relative; + .post-body--code__language { + position: absolute; + right: 0; + background: #fff; + cursor: default; + padding: 0.3em 0.5em 0.1em; + border-bottom-left-radius: 4px; + @include opacity(.3); + } + code { + white-space: pre; + } + } } .create-reply-form-wrap { width: 100%; diff --git a/web/static/css/highlight b/web/static/css/highlight new file mode 120000 index 0000000000..c774cf3974 --- /dev/null +++ b/web/static/css/highlight @@ -0,0 +1 @@ +../../react/node_modules/highlight.js/styles/ \ No newline at end of file diff --git a/web/templates/head.html b/web/templates/head.html index 041831ed7a..837cfb133b 100644 --- a/web/templates/head.html +++ b/web/templates/head.html @@ -24,6 +24,7 @@ + From 0f62befef06f7fc467571a87affdfa95fa1fbb81 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sat, 24 Oct 2015 15:50:20 +0200 Subject: [PATCH 17/65] code style theme chooser --- .../user_settings/code_theme_chooser.jsx | 55 ++++++++++++++++++ .../user_settings_appearance.jsx | 23 +++++++- web/react/utils/constants.jsx | 7 +++ web/react/utils/utils.jsx | 26 +++++++++ .../images/themes/code_themes/github.png | Bin 0 -> 9648 bytes .../images/themes/code_themes/monokai.png | Bin 0 -> 9303 bytes .../themes/code_themes/solarized_dark.png | Bin 0 -> 8172 bytes .../themes/code_themes/solarized_light.png | Bin 0 -> 8860 bytes web/templates/head.html | 2 +- 9 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 web/react/components/user_settings/code_theme_chooser.jsx create mode 100644 web/static/images/themes/code_themes/github.png create mode 100644 web/static/images/themes/code_themes/monokai.png create mode 100644 web/static/images/themes/code_themes/solarized_dark.png create mode 100644 web/static/images/themes/code_themes/solarized_light.png diff --git a/web/react/components/user_settings/code_theme_chooser.jsx b/web/react/components/user_settings/code_theme_chooser.jsx new file mode 100644 index 0000000000..eef4b24bae --- /dev/null +++ b/web/react/components/user_settings/code_theme_chooser.jsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +var Constants = require('../../utils/constants.jsx'); + +export default class CodeThemeChooser extends React.Component { + constructor(props) { + super(props); + this.state = {}; + } + render() { + const theme = this.props.theme; + + const premadeThemes = []; + for (const k in Constants.CODE_THEMES) { + if (Constants.CODE_THEMES.hasOwnProperty(k)) { + let activeClass = ''; + if (k === theme.codeTheme) { + activeClass = 'active'; + } + + premadeThemes.push( +
+
this.props.updateTheme(k)} + > + +
+
+ ); + } + } + + return ( +
+ {premadeThemes} +
+ ); + } +} + +CodeThemeChooser.propTypes = { + theme: React.PropTypes.object.isRequired, + updateTheme: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index 8c62a189dd..e94894a1d9 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -7,6 +7,7 @@ var Utils = require('../../utils/utils.jsx'); const CustomThemeChooser = require('./custom_theme_chooser.jsx'); const PremadeThemeChooser = require('./premade_theme_chooser.jsx'); +const CodeThemeChooser = require('./code_theme_chooser.jsx'); const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx'); const Constants = require('../../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; @@ -18,12 +19,14 @@ export default class UserSettingsAppearance extends React.Component { this.onChange = this.onChange.bind(this); this.submitTheme = this.submitTheme.bind(this); this.updateTheme = this.updateTheme.bind(this); + this.updateCodeTheme = this.updateCodeTheme.bind(this); this.handleClose = this.handleClose.bind(this); this.handleImportModal = this.handleImportModal.bind(this); this.state = this.getStateFromStores(); this.originalTheme = this.state.theme; + this.originalCodeTheme = this.state.theme.codeTheme; } componentDidMount() { UserStore.addChangeListener(this.onChange); @@ -58,6 +61,10 @@ export default class UserSettingsAppearance extends React.Component { type = 'custom'; } + if (!theme.codeTheme) { + theme.codeTheme = Constants.DEFAULT_CODE_THEME; + } + return {theme, type}; } onChange() { @@ -93,6 +100,13 @@ export default class UserSettingsAppearance extends React.Component { ); } updateTheme(theme) { + theme.codeTheme = this.state.theme.codeTheme; + this.setState({theme}); + Utils.applyTheme(theme); + } + updateCodeTheme(codeTheme) { + var theme = this.state.theme; + theme.codeTheme = codeTheme; this.setState({theme}); Utils.applyTheme(theme); } @@ -102,6 +116,7 @@ export default class UserSettingsAppearance extends React.Component { handleClose() { const state = this.getStateFromStores(); state.serverError = null; + state.theme.codeTheme = this.originalCodeTheme; Utils.applyTheme(state.theme); @@ -170,7 +185,13 @@ export default class UserSettingsAppearance extends React.Component {
{custom}
- {serverError} + {'Code Theme'} + +
+ {serverError} { + changeCss('code.hljs', 'visibility: visible'); + }); + } else { + changeCss('code.hljs', 'visibility: visible'); + } + }; + xmlHTTP.send(); + } +} + export function placeCaretAtEnd(el) { el.focus(); if (typeof window.getSelection != 'undefined' && typeof document.createRange != 'undefined') { diff --git a/web/static/images/themes/code_themes/github.png b/web/static/images/themes/code_themes/github.png new file mode 100644 index 0000000000000000000000000000000000000000..d0538d6c0bf154f96fd298f554c2eb62c39fca91 GIT binary patch literal 9648 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 mzg0tO5H;d4sraE_@D0000 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 - + From 9fd9fb1722edfa4aa2e77aa25abfe3e317160839 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sat, 24 Oct 2015 16:03:12 +0200 Subject: [PATCH 18/65] fix markup if code is of unknown language --- web/react/utils/markdown.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 8cfcfdefd7..f1d15615eb 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -68,7 +68,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer { code(code, language) { if (!language || highlightJs.listLanguages().indexOf(language) < 0) { let parsed = super.code(code, language); - return '' + parsed.substr(11, parsed.length - 17); + return '' + $(parsed).text() + ''; } let parsed = highlightJs.highlight(language, code); From 51032ae11de9158ea6a4a4be6b73c621b1c75f2a Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sat, 24 Oct 2015 21:55:16 +0200 Subject: [PATCH 19/65] Add java and ini language Forgot to add those altough they are quite common --- web/react/utils/constants.jsx | 4 +++- web/react/utils/markdown.jsx | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 9c079bb876..c20d84f403 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -342,6 +342,8 @@ module.exports = { cpp: 'C++', sql: 'SQL', go: 'Go', - ruby: 'Ruby' + ruby: 'Ruby', + java: 'Java', + ini: 'ini' } }; diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index f1d15615eb..01cc309b84 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -28,6 +28,8 @@ const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); const highlightJsSql = require('highlight.js/lib/languages/sql.js'); const highlightJsGo = require('highlight.js/lib/languages/go.js'); const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); +const highlightJsJava = require('highlight.js/lib/languages/java.js'); +const highlightJsIni = require('highlight.js/lib/languages/ini.js'); const Constants = require('../utils/constants.jsx'); const HighlightedLanguages = Constants.HighlightedLanguages; @@ -63,6 +65,8 @@ export class MattermostMarkdownRenderer extends marked.Renderer { highlightJs.registerLanguage('sql', highlightJsSql); highlightJs.registerLanguage('go', highlightJsGo); highlightJs.registerLanguage('ruby', highlightJsRuby); + highlightJs.registerLanguage('java', highlightJsJava); + highlightJs.registerLanguage('ini', highlightJsIni); } code(code, language) { From 0c0453559974dcdd2a7fa6fb9c8d72fdbc4082c7 Mon Sep 17 00:00:00 2001 From: Pat Lathem Date: Sat, 24 Oct 2015 18:32:45 -0500 Subject: [PATCH 20/65] Remove trailing punctuation when parsing @username references --- web/react/utils/text_formatting.jsx | 60 ++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index d79aeed68c..9fd22e6b94 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -82,6 +82,7 @@ export function sanitizeHtml(text) { return output; } +// Convert URLs into tokens function autolinkUrls(text, tokens) { function replaceUrlWithToken(autolinker, match) { const linkText = match.getMatchedText(); @@ -123,27 +124,60 @@ function autolinkUrls(text, tokens) { } function autolinkAtMentions(text, tokens) { - let output = text; + // Return true if provided character is punctuation + function isPunctuation(character) { + const re = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]/g; + return re.test(character); + } + + // Test if provided text needs to be highlighted, special mention or current user + function mentionExists(u) { + return (Constants.SPECIAL_MENTIONS.indexOf(u) !== -1 || UserStore.getProfileByUsername(u)); + } + + function addToken(username, mention, extraText) { + const index = tokens.size; + const alias = `MM_ATMENTION${index}`; + + tokens.set(alias, { + value: `${mention}${extraText}`, + originalText: mention + }); + return alias; + } function replaceAtMentionWithToken(fullMatch, prefix, mention, username) { - const usernameLower = username.toLowerCase(); - if (Constants.SPECIAL_MENTIONS.indexOf(usernameLower) !== -1 || UserStore.getProfileByUsername(usernameLower)) { - const index = tokens.size; - const alias = `MM_ATMENTION${index}`; - - tokens.set(alias, { - value: `${mention}`, - originalText: mention - }); - + let usernameLower = username.toLowerCase(); + + if (mentionExists(usernameLower)) { + // Exact match + const alias = addToken(usernameLower, mention, ''); return prefix + alias; + } + + // Not an exact match, attempt to truncate any punctuation to see if we can find a user + const originalUsername = usernameLower; + + for (let c = usernameLower.length; c > 0; c--) { + if (isPunctuation(usernameLower[c-1])) { + usernameLower = usernameLower.substring(0, c); + + if (mentionExists(usernameLower)) { + const extraText = originalUsername.substr(c); + const alias = addToken(usernameLower, mention, extraText); + return prefix + alias; + } + } else { + // If the last character is not punctuation, no point in going any further + break; + } } return fullMatch; } - output = output.replace(/(^|\s)(@([a-z0-9.\-_]*[a-z0-9]))/gi, replaceAtMentionWithToken); - + let output = text; + output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); return output; } From 80e0a8db1d70ca387c654d9ac6bded0fb1e352a6 Mon Sep 17 00:00:00 2001 From: Pat Lathem Date: Sat, 24 Oct 2015 18:54:01 -0500 Subject: [PATCH 21/65] Fix off by one error --- web/react/utils/text_formatting.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 9fd22e6b94..c49bdf9164 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -148,23 +148,23 @@ function autolinkAtMentions(text, tokens) { function replaceAtMentionWithToken(fullMatch, prefix, mention, username) { let usernameLower = username.toLowerCase(); - + if (mentionExists(usernameLower)) { // Exact match const alias = addToken(usernameLower, mention, ''); return prefix + alias; - } + } // Not an exact match, attempt to truncate any punctuation to see if we can find a user const originalUsername = usernameLower; - + for (let c = usernameLower.length; c > 0; c--) { - if (isPunctuation(usernameLower[c-1])) { - usernameLower = usernameLower.substring(0, c); + if (isPunctuation(usernameLower[c - 1])) { + usernameLower = usernameLower.substring(0, c - 1); if (mentionExists(usernameLower)) { - const extraText = originalUsername.substr(c); - const alias = addToken(usernameLower, mention, extraText); + const extraText = originalUsername.substr(c - 1); + const alias = addToken(usernameLower, '@' + usernameLower, extraText); return prefix + alias; } } else { From 73e6f74edfed0a2dda1ccc3d6d60128a9c607c10 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 18:02:36 +0100 Subject: [PATCH 22/65] Set correct mime type for profile images --- api/user.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/user.go b/api/user.go index 3071e1b26c..06e5336f12 100644 --- a/api/user.go +++ b/api/user.go @@ -814,6 +814,7 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=86400, public") // 24 hrs } + w.Header().Set("Content-Type", "image/png") w.Write(img) } } From 9be2f341948bdab34bc2c07755d12faa6795cb70 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 18:37:48 +0100 Subject: [PATCH 23/65] Fix /shrug command --- api/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/command.go b/api/command.go index 54f863c484..35ca5816b3 100644 --- a/api/command.go +++ b/api/command.go @@ -165,7 +165,7 @@ func shrugCommand(c *Context, command *model.Command) bool { cmd := "/shrug" if !command.Suggest && strings.Index(command.Command, cmd) == 0 { - message := "¯\\_(ツ)_/¯" + message := `¯\\\_(ツ)_/¯` parameters := strings.SplitN(command.Command, " ", 2) if len(parameters) > 1 { From fd8c213cd99fc8f27645b99e17c944ee74c47964 Mon Sep 17 00:00:00 2001 From: lindy65 Date: Sun, 25 Oct 2015 20:19:33 +0200 Subject: [PATCH 24/65] Updated to be in line with doc guidelines --- doc/integrations/Single-Sign-On/Gitlab.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/integrations/Single-Sign-On/Gitlab.md b/doc/integrations/Single-Sign-On/Gitlab.md index 0a8a1bd189..7939c47fb4 100644 --- a/doc/integrations/Single-Sign-On/Gitlab.md +++ b/doc/integrations/Single-Sign-On/Gitlab.md @@ -1,6 +1,6 @@ ## Configuring GitLab Single-Sign-On -The following steps can be used to configure Mattermost to use GitLab as a single-sign-on (SSO) service for team creation, account creation and sign-in. +Follow these steps to configure Mattermost to use GitLab as a single-sign-on (SSO) service for team creation, account creation and sign-in. 1. Login to your GitLab account and go to the Applications section either in Profile Settings or Admin Area. 2. Add a new application called "Mattermost" with the following as Redirect URIs: @@ -18,6 +18,6 @@ The following steps can be used to configure Mattermost to use GitLab as a singl Note: Make sure your `HTTPS` or `HTTP` prefix for endpoint URLs matches your server configuration. -5. (Optional) If you would like to force all users to sign-up with GitLab only, in the _ServiceSettings_ section of config/config.json please set _DisableEmailSignUp_ to `true`. +5. (Optional) If you would like to force all users to sign-up with GitLab only, in the _ServiceSettings_ section of config/config.json set _DisableEmailSignUp_ to `true`. 6. Restart your Mattermost server to see the changes take effect. From 3a588fbc18bb990e07e656a41d2f858fb9dc25e2 Mon Sep 17 00:00:00 2001 From: Pat Lathem Date: Sun, 25 Oct 2015 14:09:09 -0500 Subject: [PATCH 25/65] Fix highlighting of trailing punctuation for own username --- web/react/utils/text_formatting.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index c49bdf9164..e47aca39bc 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -140,8 +140,9 @@ function autolinkAtMentions(text, tokens) { const alias = `MM_ATMENTION${index}`; tokens.set(alias, { - value: `${mention}${extraText}`, - originalText: mention + value: `${mention}`, + originalText: mention, + extraText }); return alias; } @@ -194,10 +195,9 @@ function highlightCurrentMentions(text, tokens) { const newAlias = `MM_SELFMENTION${index}`; newTokens.set(newAlias, { - value: `${alias}`, + value: `${alias}` + token.extraText, originalText: token.originalText }); - output = output.replace(alias, newAlias); } } From 6afe95158c9cda38bb87ef193e77435f339b846b Mon Sep 17 00:00:00 2001 From: it33 Date: Sun, 25 Oct 2015 13:37:06 -0700 Subject: [PATCH 26/65] Update websocket error Getting this error when websockets is properly configured and connection is just slow --- web/react/utils/client.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index bc73f3c64a..a93257dd2a 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -34,7 +34,7 @@ function handleError(methodName, xhr, status, err) { if (oldError && oldError.connErrorCount) { errorCount += oldError.connErrorCount; - connectError = 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'; + connectError = 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'; } e = {message: connectError, connErrorCount: errorCount}; From af62f4d57112d35ba9da37216eaed72c18923102 Mon Sep 17 00:00:00 2001 From: it33 Date: Sun, 25 Oct 2015 13:37:49 -0700 Subject: [PATCH 27/65] Update help --- web/react/stores/socket_store.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 9410c1e9cc..455b5b0427 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -86,7 +86,7 @@ class SocketStoreClass extends EventEmitter { this.failCount = this.failCount + 1; - ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'}); + ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'}); ErrorStore.emitChange(); }; From c6bbebbf6f5bc6fd1b4af222e7043cee0dd24f1f Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 23:55:50 +0100 Subject: [PATCH 28/65] Mattermost can not send message start with slash resolves #827 --- api/command.go | 75 ++++++++++++----- model/command.go | 3 +- web/react/components/create_post.jsx | 117 +++++++++++++++------------ 3 files changed, 121 insertions(+), 74 deletions(-) diff --git a/api/command.go b/api/command.go index 54f863c484..30966739d9 100644 --- a/api/command.go +++ b/api/command.go @@ -17,14 +17,23 @@ import ( type commandHandler func(c *Context, command *model.Command) bool -var commands = []commandHandler{ - logoutCommand, - joinCommand, - loadTestCommand, - echoCommand, - shrugCommand, -} - +var ( + cmds = map[string]string{ + "logoutCommand": "/logout", + "joinCommand": "/join", + "loadTestCommand": "/loadtest", + "echoCommand": "/echo", + "shrugCommand": "/shrug", + } + commands = []commandHandler{ + logoutCommand, + joinCommand, + loadTestCommand, + echoCommand, + shrugCommand, + } + commandNotImplementedErr = model.NewAppError("checkCommand", "Command not implemented", "") +) var echoSem chan bool func InitCommand(r *mux.Router) { @@ -45,7 +54,14 @@ func command(c *Context, w http.ResponseWriter, r *http.Request) { checkCommand(c, command) if c.Err != nil { - return + if c.Err != commandNotImplementedErr { + return + } else { + c.Err = nil + command.Response = model.RESP_NOT_IMPLEMENTED + w.Write([]byte(command.ToJson())) + return + } } else { w.Write([]byte(command.ToJson())) } @@ -66,6 +82,23 @@ func checkCommand(c *Context, command *model.Command) bool { } } + if !command.Suggest { + implemented := false + for _, cmd := range cmds { + bounds := len(cmd) + if len(command.Command) < bounds { + continue + } + if command.Command[:bounds] == cmd { + implemented = true + } + } + if !implemented { + c.Err = commandNotImplementedErr + return false + } + } + for _, v := range commands { if v(c, command) || c.Err != nil { @@ -78,7 +111,7 @@ func checkCommand(c *Context, command *model.Command) bool { func logoutCommand(c *Context, command *model.Command) bool { - cmd := "/logout" + cmd := cmds["logoutCommand"] if strings.Index(command.Command, cmd) == 0 { command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd, Description: "Logout"}) @@ -97,7 +130,7 @@ func logoutCommand(c *Context, command *model.Command) bool { } func echoCommand(c *Context, command *model.Command) bool { - cmd := "/echo" + cmd := cmds["echoCommand"] maxThreads := 100 if !command.Suggest && strings.Index(command.Command, cmd) == 0 { @@ -162,7 +195,7 @@ func echoCommand(c *Context, command *model.Command) bool { } func shrugCommand(c *Context, command *model.Command) bool { - cmd := "/shrug" + cmd := cmds["shrugCommand"] if !command.Suggest && strings.Index(command.Command, cmd) == 0 { message := "¯\\_(ツ)_/¯" @@ -192,7 +225,7 @@ func shrugCommand(c *Context, command *model.Command) bool { func joinCommand(c *Context, command *model.Command) bool { // looks for "/join channel-name" - cmd := "/join" + cmd := cmds["joinCommand"] if strings.Index(command.Command, cmd) == 0 { @@ -242,7 +275,7 @@ func joinCommand(c *Context, command *model.Command) bool { } func loadTestCommand(c *Context, command *model.Command) bool { - cmd := "/loadtest" + cmd := cmds["loadTestCommand"] // This command is only available when EnableTesting is true if !utils.Cfg.ServiceSettings.EnableTesting { @@ -304,7 +337,7 @@ func contains(items []string, token string) bool { } func loadTestSetupCommand(c *Context, command *model.Command) bool { - cmd := "/loadtest setup" + cmd := cmds["loadTestCommand"] + " setup" if strings.Index(command.Command, cmd) == 0 && !command.Suggest { tokens := strings.Fields(strings.TrimPrefix(command.Command, cmd)) @@ -390,8 +423,8 @@ func loadTestSetupCommand(c *Context, command *model.Command) bool { } func loadTestUsersCommand(c *Context, command *model.Command) bool { - cmd1 := "/loadtest users" - cmd2 := "/loadtest users fuzz" + cmd1 := cmds["loadTestCommand"] + " users" + cmd2 := cmds["loadTestCommand"] + " users fuzz" if strings.Index(command.Command, cmd1) == 0 && !command.Suggest { cmd := cmd1 @@ -420,8 +453,8 @@ func loadTestUsersCommand(c *Context, command *model.Command) bool { } func loadTestChannelsCommand(c *Context, command *model.Command) bool { - cmd1 := "/loadtest channels" - cmd2 := "/loadtest channels fuzz" + cmd1 := cmds["loadTestCommand"] + " channels" + cmd2 := cmds["loadTestCommand"] + " channels fuzz" if strings.Index(command.Command, cmd1) == 0 && !command.Suggest { cmd := cmd1 @@ -451,8 +484,8 @@ func loadTestChannelsCommand(c *Context, command *model.Command) bool { } func loadTestPostsCommand(c *Context, command *model.Command) bool { - cmd1 := "/loadtest posts" - cmd2 := "/loadtest posts fuzz" + cmd1 := cmds["loadTestCommand"] + " posts" + cmd2 := cmds["loadTestCommand"] + " posts fuzz" if strings.Index(command.Command, cmd1) == 0 && !command.Suggest { cmd := cmd1 diff --git a/model/command.go b/model/command.go index 2b26aad1c9..5aec5f5343 100644 --- a/model/command.go +++ b/model/command.go @@ -9,7 +9,8 @@ import ( ) const ( - RESP_EXECUTED = "executed" + RESP_EXECUTED = "executed" + RESP_NOT_IMPLEMENTED = "not implemented" ) type Command struct { diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 8b5fc4162b..055be112db 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -38,6 +38,7 @@ export default class CreatePost extends React.Component { this.getFileCount = this.getFileCount.bind(this); this.handleArrowUp = this.handleArrowUp.bind(this); this.handleResize = this.handleResize.bind(this); + this.sendMessage = this.sendMessage.bind(this); PostStore.clearDraftUploads(); @@ -122,6 +123,11 @@ export default class CreatePost extends React.Component { post.message, false, (data) => { + if (data.response === 'not implemented') { + this.sendMessage(post); + return; + } + PostStore.storeDraft(data.channel_id, null); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); @@ -130,63 +136,70 @@ export default class CreatePost extends React.Component { } }, (err) => { - const state = {}; - state.serverError = err.message; - state.submitting = false; - this.setState(state); + if (err.sendMessage) { + this.sendMessage(post); + } else { + const state = {}; + state.serverError = err.message; + state.submitting = false; + this.setState(state); + } } ); } else { - post.channel_id = this.state.channelId; - post.filenames = this.state.previews; - - const time = Utils.getTimestamp(); - const userId = UserStore.getCurrentId(); - post.pending_post_id = `${userId}:${time}`; - post.user_id = userId; - post.create_at = time; - post.root_id = this.state.rootId; - post.parent_id = this.state.parentId; - - const channel = ChannelStore.get(this.state.channelId); - - PostStore.storePendingPost(post); - PostStore.storeDraft(channel.id, null); - this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); - - Client.createPost(post, channel, - (data) => { - AsyncClient.getPosts(); - - const member = ChannelStore.getMember(channel.id); - member.msg_count = channel.total_msg_count; - member.last_viewed_at = Date.now(); - ChannelStore.setChannelMember(member); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST, - post: data - }); - }, - (err) => { - const state = {}; - - if (err.message === 'Invalid RootId parameter') { - if ($('#post_deleted').length > 0) { - $('#post_deleted').modal('show'); - } - PostStore.removePendingPost(post.pending_post_id); - } else { - post.state = Constants.POST_FAILED; - PostStore.updatePendingPost(post); - } - - state.submitting = false; - this.setState(state); - } - ); + this.sendMessage(post); } } + sendMessage(post) { + post.channel_id = this.state.channelId; + post.filenames = this.state.previews; + + const time = Utils.getTimestamp(); + const userId = UserStore.getCurrentId(); + post.pending_post_id = `${userId}:${time}`; + post.user_id = userId; + post.create_at = time; + post.root_id = this.state.rootId; + post.parent_id = this.state.parentId; + + const channel = ChannelStore.get(this.state.channelId); + + PostStore.storePendingPost(post); + PostStore.storeDraft(channel.id, null); + this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); + + Client.createPost(post, channel, + (data) => { + AsyncClient.getPosts(); + + const member = ChannelStore.getMember(channel.id); + member.msg_count = channel.total_msg_count; + member.last_viewed_at = Date.now(); + ChannelStore.setChannelMember(member); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST, + post: data + }); + }, + (err) => { + const state = {}; + + if (err.message === 'Invalid RootId parameter') { + if ($('#post_deleted').length > 0) { + $('#post_deleted').modal('show'); + } + PostStore.removePendingPost(post.pending_post_id); + } else { + post.state = Constants.POST_FAILED; + PostStore.updatePendingPost(post); + } + + state.submitting = false; + this.setState(state); + } + ); + } postMsgKeyPress(e) { if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { e.preventDefault(); From ae2898107d275176126ab07ca1886fd7fd7ddad4 Mon Sep 17 00:00:00 2001 From: it33 Date: Sun, 25 Oct 2015 20:36:37 -0700 Subject: [PATCH 29/65] Added not on 3-char limitation --- doc/help/Search.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 4cfde35256cecab12d317e0d829769143f4df2d0 Mon Sep 17 00:00:00 2001 From: Girish S Date: Mon, 26 Oct 2015 12:25:14 +0530 Subject: [PATCH 30/65] append * to search query if not present and highlight partial matches --- web/react/components/search_bar.jsx | 4 ++++ web/react/utils/text_formatting.jsx | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index e1d36ad7dd..fae26f8037 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -105,6 +105,10 @@ export default class SearchBar extends React.Component { performSearch(terms, isMentionSearch) { if (terms.length) { this.setState({isSearching: true}); + + if(terms.search(/\*\s*$/) == -1) // append * if not present + terms = terms + "*"; + client.search( terms, (data) => { diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 5c2e68f1e6..75f6cb714a 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -243,10 +243,11 @@ function autolinkHashtags(text, tokens) { function highlightSearchTerm(text, tokens, searchTerm) { let output = text; + searchTerm = searchTerm.replace(/\*$/, ''); var newTokens = new Map(); for (const [alias, token] of tokens) { - if (token.originalText === searchTerm) { + if (token.originalText.indexOf(searchTerm) > -1) { const index = tokens.size + newTokens.size; const newAlias = `MM_SEARCHTERM${index}`; @@ -276,7 +277,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { return prefix + alias; } - return output.replace(new RegExp(`(^|\\W)(${searchTerm})\\b`, 'gi'), replaceSearchTermWithToken); + return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); } function replaceTokens(text, tokens) { From e9812655f6cb7e9e6c06bc1b2a462efc106f52f7 Mon Sep 17 00:00:00 2001 From: Girish S Date: Mon, 26 Oct 2015 13:00:26 +0530 Subject: [PATCH 31/65] made eslint happy --- web/react/components/search_bar.jsx | 9 ++++++--- web/react/utils/text_formatting.jsx | 5 ++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index fae26f8037..0da43e8cdd 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -106,11 +106,14 @@ export default class SearchBar extends React.Component { if (terms.length) { this.setState({isSearching: true}); - if(terms.search(/\*\s*$/) == -1) // append * if not present - terms = terms + "*"; + // append * if not present + let searchTerms = terms; + if (searchTerms.search(/\*\s*$/) === -1) { + searchTerms = searchTerms + '*'; + } client.search( - terms, + searchTerms, (data) => { this.setState({isSearching: false}); if (utils.isMobile()) { diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 75f6cb714a..204c373644 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -243,11 +243,10 @@ function autolinkHashtags(text, tokens) { function highlightSearchTerm(text, tokens, searchTerm) { let output = text; - searchTerm = searchTerm.replace(/\*$/, ''); var newTokens = new Map(); for (const [alias, token] of tokens) { - if (token.originalText.indexOf(searchTerm) > -1) { + if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) { const index = tokens.size + newTokens.size; const newAlias = `MM_SEARCHTERM${index}`; @@ -277,7 +276,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { return prefix + alias; } - return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); + return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); } function replaceTokens(text, tokens) { From 5d25e55254ce8060a69a0cfdbbfbd4babe77a860 Mon Sep 17 00:00:00 2001 From: Girish S Date: Mon, 26 Oct 2015 14:48:00 +0530 Subject: [PATCH 32/65] strips extra hiphens from channel url --- web/react/utils/utils.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 67a9d69834..1f24cd634e 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -20,6 +20,7 @@ export function isEmail(email) { export function cleanUpUrlable(input) { var cleaned = input.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-'); + cleaned = cleaned.replace(/-{2,}/, '-'); cleaned = cleaned.replace(/^\-+/, ''); cleaned = cleaned.replace(/\-+$/, ''); return cleaned; From 79dda565f80687e1acc6c882aaad268451507024 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 22 Oct 2015 13:44:59 -0400 Subject: [PATCH 33/65] Started outgoing webhook doc --- .../webhooks/Incoming-Webhooks.md | 6 +- .../webhooks/Outgoing-Webhooks.md | 94 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 doc/integrations/webhooks/Outgoing-Webhooks.md diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md index 7340282be1..65a7953d55 100644 --- a/doc/integrations/webhooks/Incoming-Webhooks.md +++ b/doc/integrations/webhooks/Incoming-Webhooks.md @@ -4,8 +4,8 @@ Incoming webhooks allow external applications, written in the programming langua A couple key points: -- **Mattermost incoming webhooks are Slack-compatible.** If you've used Slack's incoming webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's propretiary JSON payload format into markdown to render in Mattermost messages. -- **Mattermost incoming webhooks support full markdown.** A rich range of formatting unavailable in Slack is made possible through [markdown support](../../usage/Markdown.md) in Mattermost, incuding headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown]. +- **Mattermost incoming webhooks are Slack-compatible.** If you've used Slack's incoming webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages. +- **Mattermost incoming webhooks support full markdown.** A rich range of formatting unavailable in Slack is made possible through [markdown support](../../usage/Markdown.md) in Mattermost, including headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown]. _Example:_ @@ -63,7 +63,7 @@ curl -i -X POST -d 'payload={"text": "Hello, this is some text."}' http://yourma 3. Build your integration in the programming language of your choice 1. Most integrations will be used to translate some sort of output from another system to an appropriately formatted input that will be passed into the Mattermost webhook URL. For example, an integration could take events generated by [GitLab outgoing webhooks](http://doc.gitlab.com/ee/web_hooks/web_hooks.html) and parse them into a JSON body to post into Mattermost. 1. To get the message posted into Mattermost, your integration will need to create an HTTP POST request that will submit to the incoming webhook URL you created before. The body of the request must have a `payload` that contains a JSON object that specifies a `text` parameter. For example, `payload={"text": "Hello, this is some text."}` is a valid body for a request. - 2. Setup your integration running on Heroku, an AWS server or a server of your own to start sending real time updates to Mattermost channels and private groups. + 2. Set up your integration running on Heroku, an AWS server or a server of your own to start sending real time updates to Mattermost channels and private groups. Additional Notes: diff --git a/doc/integrations/webhooks/Outgoing-Webhooks.md b/doc/integrations/webhooks/Outgoing-Webhooks.md new file mode 100644 index 0000000000..afe059e9f0 --- /dev/null +++ b/doc/integrations/webhooks/Outgoing-Webhooks.md @@ -0,0 +1,94 @@ +# Outgoing Webhooks + +Outgoing webhooks allow external applications, written in the programming language of your choice--to receive HTTP POST requests whenever a user posts to a certain channel, with a trigger word at the beginning of the message, or a combination of both. If the external application responds appropriately to the HTTP request, as response post can be made in the channel where the original post occurred. + +A couple key points: + +- **Mattermost outgoing webhooks are Slack-compatible.** If you've used Slack's outgoing webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages. +- **Mattermost outgoing webhooks support full markdown.** When an integration responds with a message to post, it will have access to a rich range of formatting unavailable in Slack that is made possible through [markdown support](../../usage/Markdown.md) in Mattermost. This includes headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown]. + +_Example:_ + +Suppose you had an external application that recieved a post event asking for the status of a daily build. The application could respond with a table of total tests run and total tests failed by component category, with links to failed tests by category. An example response the application could respond with would be: +``` +{"text": " +--- +##### Build Break - Project X - December 12, 2015 - 15:32 GMT +0 +| Component | Tests Run | Tests Failed | +|:-----------|:------------|:-----------------------------------------------| +| Server | 948 | :white_check_mark: 0 | +| Web Client | 123 | :warning: [2 (see details)](http://linktologs) | +| iOS Client | 78 | :warning: [3 (see details)](http://linktologs) | +--- +"} +``` +Which would render in a Mattermost message as follows: + +--- +##### Build Break - Project X - December 12, 2015 - 15:32 GMT +0 +| Component | Tests Run | Tests Failed | +|:-----------|:------------|:-----------------------------------------------| +| Server | 948 | :white_check_mark: 0 | +| Web Client | 123 | :warning: [2 (see details)](http://linktologs) | +| iOS Client | 78 | :warning: [3 (see details)](http://linktologs) | +--- + +### Enabling Outgoing Webhooks +Outgoing webhooks should be enabled on your Mattermost instance by default, but if they are not you'll need to get your system administrator to enable them. If you are the system administrator you can enable them by doing the following: + +1. Login to your Mattermost team account that has the system administrator role. +1. Enable outgoing webhooks from **System Console -> Service Settings**. +1. (Optional) Configure the **Enable Overriding of Usernames from Webhooks** option to allow external applications to post messages under any name. If not enabled, the username of the creator of the webhook URL is used to post messages. +2. (Optional) Configure the **Enable Overriding of Icon from Webhooks** option to allow external applciations to change the icon of the account posting messages. If not enabled, the icon of the creator of the webhook URL is used to post messages. + +### Set Up an Outgoing Webhook +Once outgoing webhooks are enabled, you will be able to set one up through the Mattermost UI. You will need to know the following + +1. The channel (if not all of them) you want to listen to post events from +2. The trigger words (if any) at the beginning of the post will trigger a post event +3. The URL you want Mattermost to report the events to + +Once you have those, you can follow these steps to set up your webhook: + +1. Login to your Mattermost team site and go to **Account Settings -> Integrations** +2. Next to **Outgoing Webhooks** click **Edit** +3. Under **Add a new outgoing webhook** select your options + 1. Select a channel from the **Channel** dropdown to only report events from a certain channel (optional if Trigger Words selected) + 2. Enter comma separated words into **Trigger Words** to only report events from posts that start with one of those words (optional if **Channel** selected) + 3. Enter new line separated URLs that the post events will be sent too +4. Click **Add** to add your webhook to the system +5. Your new outgoing webhook will be displayed below with a **Token** that any external application that wants to listen to the webhook should ask for in it's instructions + +### Creating Integrations using Outgoing Webhooks + +If you'd like to build your own integration that uses outgoing webhooks, you can follow these general guidelines: + +1. In the programming language of your choice, write your integration to perform what you had in mind + 1. Your integration should have a function for receiving HTTP POSTs from Mattermost that look like this example: + ``` + Content-Length: 244 + User-Agent: Go 1.1 package http + Host: localhost:5000 + Accept: application/json + Content-Type: application/x-www-form-urlencoded + Accept-Encoding: gzip + + channel_id=hawos4dqtby53pd64o4a4cmeoo& + channel_name=town-square& + team_domain=someteam& + team_id=kwoknj9nwpypzgzy78wkw516qe& + text=some text here& + timestamp=1445532266& + token=zmigewsanbbsdf59xnmduzypjc& + trigger_word=some& + user_id=rnina9994bde8mua79zqcg5hmo& + user_name=somename + ``` + 2. Your integration must have a configurable **MATTERMOST_TOKEN** variable that is the Token given to you when you set up the outgoing webhook in Mattermost as decribed in the previous section _Set Up an Outgoing Webhook_. This configurable **MATTERMOST_TOKEN** must match the token in the request body so your application can be sure the request came from Mattermost + 3. If you want your integration to post a message back to the same channel, it can respond to the HTTP POST request from Mattermost with a JSON response body similar to this example: + ``` + { + "text": "This some response text." + } + ``` +2. Set up your integration running on Heroku, an AWS server or a server of your own to start getting real time post events from Mattermost channels From 250a504b0ec279ec7299799b6c2e04173bc82486 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 23 Oct 2015 10:32:16 -0400 Subject: [PATCH 34/65] Finish first draft of outgoing webhook doc --- .../webhooks/Incoming-Webhooks.md | 38 +++++++++---------- .../webhooks/Outgoing-Webhooks.md | 26 +++++++++++-- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md index 65a7953d55..b040dc8474 100644 --- a/doc/integrations/webhooks/Incoming-Webhooks.md +++ b/doc/integrations/webhooks/Incoming-Webhooks.md @@ -4,8 +4,8 @@ Incoming webhooks allow external applications, written in the programming langua A couple key points: -- **Mattermost incoming webhooks are Slack-compatible.** If you've used Slack's incoming webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages. -- **Mattermost incoming webhooks support full markdown.** A rich range of formatting unavailable in Slack is made possible through [markdown support](../../usage/Markdown.md) in Mattermost, including headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown]. +- **Mattermost incoming webhooks are Slack-compatible.** If you've used Slack's incoming webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages +- **Mattermost incoming webhooks support full markdown.** A rich range of formatting unavailable in Slack is made possible through [markdown support](../../usage/Markdown.md) in Mattermost, including headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown] _Example:_ @@ -37,10 +37,10 @@ Which would render in a Mattermost message as follows: ### Enabling Incoming Webhooks Incoming webhooks should be enabled on your Mattermost instance by default, but if they are not you'll need to get your system administrator to enable them. If you are the system administrator you can enable them by doing the following: -1. Login to your Mattermost team account that has the system administrator role. -1. Enable incoming webhooks from **System Console -> Service Settings**. -1. (Optional) Configure the **Enable Overriding of Usernames from Webhooks** option to allow external applications to post messages under any name. If not enabled, the username of the creator of the webhook URL is used to post messages. -2. (Optional) Configure the **Enable Overriding of Icon from Webhooks** option to allow external applciations to change the icon of the account posting messages. If not enabled, the icon of the creator of the webhook URL is used to post messages. +1. Login to your Mattermost team account that has the system administrator role +1. Enable incoming webhooks from **System Console -> Service Settings** +1. (Optional) Configure the **Enable Overriding of Usernames from Webhooks** option to allow external applications to post messages under any name. If not enabled, the username of the creator of the webhook URL is used to post messages +2. (Optional) Configure the **Enable Overriding of Icon from Webhooks** option to allow external applciations to change the icon of the account posting messages. If not enabled, the icon of the creator of the webhook URL is used to post messages ### Setting Up Existing Integrations If you've already found or built an integration and are just looking to hook it up, then you should just need to follow the specific instructions of that integration. If the integration is using Mattermost incoming webhooks, then at some point in the instructions it will ask for a webhook URL. You can get this URL by following the first step in the next section _Creating Integrations using Incoming Webhooks_. @@ -54,39 +54,39 @@ You can create a webhook integration to post into Mattermost channels and privat 1. Login to your Mattermost team site and go to **Account Settings -> Integrations** 2. Next to **Incoming Webhooks** click **Edit** 3. Select the channel or private group to receive webhook payloads, then click **Add** to create the webhook - 4. To see your new webhook in action, try a curl command from your terminal or command-line to send a JSON string as the `payload` parameter in a HTTP POST request. + 4. To see your new webhook in action, try a curl command from your terminal or command-line to send a JSON string as the `payload` parameter in a HTTP POST request 1. Example: ``` curl -i -X POST -d 'payload={"text": "Hello, this is some text."}' http://yourmattermost.com/hooks/xxx-generatedkey-xxx ``` 3. Build your integration in the programming language of your choice - 1. Most integrations will be used to translate some sort of output from another system to an appropriately formatted input that will be passed into the Mattermost webhook URL. For example, an integration could take events generated by [GitLab outgoing webhooks](http://doc.gitlab.com/ee/web_hooks/web_hooks.html) and parse them into a JSON body to post into Mattermost. - 1. To get the message posted into Mattermost, your integration will need to create an HTTP POST request that will submit to the incoming webhook URL you created before. The body of the request must have a `payload` that contains a JSON object that specifies a `text` parameter. For example, `payload={"text": "Hello, this is some text."}` is a valid body for a request. - 2. Set up your integration running on Heroku, an AWS server or a server of your own to start sending real time updates to Mattermost channels and private groups. + 1. Most integrations will be used to translate some sort of output from another system to an appropriately formatted input that will be passed into the Mattermost webhook URL. For example, an integration could take events generated by [GitLab outgoing webhooks](http://doc.gitlab.com/ee/web_hooks/web_hooks.html) and parse them into a JSON body to post into Mattermost + 1. To get the message posted into Mattermost, your integration will need to create an HTTP POST request that will submit to the incoming webhook URL you created before. The body of the request must have a `payload` that contains a JSON object that specifies a `text` parameter. For example, `payload={"text": "Hello, this is some text."}` is a valid body for a request + 2. Set up your integration running on Heroku, an AWS server or a server of your own to start sending real time updates to Mattermost channels and private groups Additional Notes: 1. For the HTTP request body, if `Content-Type` is specified as `application/json` in the headers of the HTTP request then the body of the request can be direct JSON. For example, ```{"text": "Hello, this is some text."}``` -2. You can override the channel specified in the webhook definition by specifying a `channel` parameter in your payload. For example, you might have a single webhook created for _Town Square_, but you can use ```payload={"channel": "off-topic", "text": "Hello, this is some text."}``` to send a message to the _Off-Topic_ channel using the same webhook URL. +2. You can override the channel specified in the webhook definition by specifying a `channel` parameter in your payload. For example, you might have a single webhook created for _Town Square_, but you can use ```payload={"channel": "off-topic", "text": "Hello, this is some text."}``` to send a message to the _Off-Topic_ channel using the same webhook URL -1. In addition, with **Enable Overriding of Usernames from Webhooks** turned on, you can also override the username the message posts as by providing a `username` parameter in your JSON payload. For example, you might want your message looking like it came from a robot so you can use ```payload={"username": "robot", "text": "Hello, this is some text."}``` to change the username of the post to robot. Note, to combat any malicious users from trying to use this to perform [phishing attacks](https://en.wikipedia.org/wiki/Phishing) a `BOT` indicator appears next to posts coming from incoming webhooks. +1. In addition, with **Enable Overriding of Usernames from Webhooks** turned on, you can also override the username the message posts as by providing a `username` parameter in your JSON payload. For example, you might want your message looking like it came from a robot so you can use ```payload={"username": "robot", "text": "Hello, this is some text."}``` to change the username of the post to robot. Note, to combat any malicious users from trying to use this to perform [phishing attacks](https://en.wikipedia.org/wiki/Phishing) a `BOT` indicator appears next to posts coming from webhooks -2. With **Enable Overriding of Icon from Webhooks** turned on, you can similarly change the icon the message posts with by providing a link to an image in the `icon_url` parameter of your payload. For example, ```payload={"icon_url": "http://somewebsite.com/somecoolimage.jpg", "text": "Hello, this is some text."}``` will post using whatever image is located at `http://somewebsite.com/somecoolimage.jpg` as the icon for the post. +2. With **Enable Overriding of Icon from Webhooks** turned on, you can similarly change the icon the message posts with by providing a link to an image in the `icon_url` parameter of your payload. For example, ```payload={"icon_url": "http://somewebsite.com/somecoolimage.jpg", "text": "Hello, this is some text."}``` will post using whatever image is located at `http://somewebsite.com/somecoolimage.jpg` as the icon for the post -3. Also, as mentioned previously, [markdown](../../usage/Markdown.md) can be used to create richly formatted payloads, for example: ```payload={"text": "# A Header\nThe _text_ below **the** header."}``` creates a messages with a header, a carriage return and bold text for "the". +3. Also, as mentioned previously, [markdown](../../usage/Markdown.md) can be used to create richly formatted payloads, for example: ```payload={"text": "# A Header\nThe _text_ below **the** header."}``` creates a messages with a header, a carriage return and bold text for "the" -4. Just like regular posts, the text will be limited to 4000 characters at maximum. +4. Just like regular posts, the text will be limited to 4000 characters at maximum ### Slack Compatibility As mentioned above, Mattermost makes it easy to take integrations written for Slack's proprietary JSON payload format and repurpose them to become Mattermost integrations. The following automatic translations are supported: -1. Payloads designed for Slack using `<>` to note the need to hyperlink a URL, such as ```payload={"text": ""}```, are translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack. -2. Similiarly, payloads designed for Slack using `|` within a `<>` to define linked text, such as ```payload={"text": "Click for a link."}```, are also translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack. -3. Like Slack, by overriding the channel name with an @username, such as payload={"text": "Hi", channel: "@jim"}, you can send the message to a user through your direct message chat. -4. Channel names can be prepended with a #, like they are in Slack incoming webhooks, and the message will still be sent to the correct channel. +1. Payloads designed for Slack using `<>` to note the need to hyperlink a URL, such as ```payload={"text": ""}```, are translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack +2. Similiarly, payloads designed for Slack using `|` within a `<>` to define linked text, such as ```payload={"text": "Click for a link."}```, are also translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack +3. Like Slack, by overriding the channel name with an @username, such as payload={"text": "Hi", channel: "@jim"}, you can send the message to a user through your direct message chat +4. Channel names can be prepended with a #, like they are in Slack incoming webhooks, and the message will still be sent to the correct channel To see samples and community contributions, please visit . diff --git a/doc/integrations/webhooks/Outgoing-Webhooks.md b/doc/integrations/webhooks/Outgoing-Webhooks.md index afe059e9f0..627fe1298c 100644 --- a/doc/integrations/webhooks/Outgoing-Webhooks.md +++ b/doc/integrations/webhooks/Outgoing-Webhooks.md @@ -4,8 +4,8 @@ Outgoing webhooks allow external applications, written in the programming langua A couple key points: -- **Mattermost outgoing webhooks are Slack-compatible.** If you've used Slack's outgoing webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages. -- **Mattermost outgoing webhooks support full markdown.** When an integration responds with a message to post, it will have access to a rich range of formatting unavailable in Slack that is made possible through [markdown support](../../usage/Markdown.md) in Mattermost. This includes headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown]. +- **Mattermost outgoing webhooks are Slack-compatible.** If you've used Slack's outgoing webhooks to create integrations, you can copy and paste that code to create Mattermost integrations. Mattermost automatically translates Slack's proprietary JSON payload format into markdown to render in Mattermost messages +- **Mattermost outgoing webhooks support full markdown.** When an integration responds with a message to post, it will have access to a rich range of formatting unavailable in Slack that is made possible through [markdown support](../../usage/Markdown.md) in Mattermost. This includes headings, formatted fonts, tables, inline images and other options supported by [Mattermost Markdown] _Example:_ @@ -91,4 +91,24 @@ If you'd like to build your own integration that uses outgoing webhooks, you can "text": "This some response text." } ``` -2. Set up your integration running on Heroku, an AWS server or a server of your own to start getting real time post events from Mattermost channels +3. Set up your integration running on Heroku, an AWS server or a server of your own to start getting real time post events from Mattermost channels + +Additional Notes: + +1. With **Enable Overriding of Usernames from Webhooks** turned on, you can also override the username the message posts as by providing a `username` parameter in your JSON payload. For example, you might want your message looking like it came from a robot so you can use the JSON response ```{"username": "robot", "text": "Hello, this is some text."}``` to change the username of the post to robot. Note, to combat any malicious users from trying to use this to perform [phishing attacks](https://en.wikipedia.org/wiki/Phishing) a `BOT` indicator appears next to posts coming from webhooks + +2. With **Enable Overriding of Icon from Webhooks** turned on, you can similarly change the icon the message posts with by providing a link to an image in the `icon_url` parameter of your JSON response. For example, ```{"icon_url": "http://somewebsite.com/somecoolimage.jpg", "text": "Hello, this is some text."}``` will post using whatever image is located at `http://somewebsite.com/somecoolimage.jpg` as the icon for the post + +3. Also, as mentioned previously, [markdown](../../usage/Markdown.md) can be used to create richly formatted payloads, for example: ```payload={"text": "# A Header\nThe _text_ below **the** header."}``` creates a messages with a header, a carriage return and bold text for "the" + +4. Just like regular posts, the text will be limited to 4000 characters at maximum + +### Slack Compatibility + +As mentioned above, Mattermost makes it easy to take integrations written for Slack's proprietary JSON payload format and repurpose them to become Mattermost integrations. The following automatic translations are supported: + +1. The HTTP POST request body is formatted the same as Slack's, which means your Slack integration's receiving function should not need to change at all to be compatible with Mattermost +2. JSON responses designed for Slack using `<>` to note the need to hyperlink a URL, such as ```{"text": ""}```, are translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack +3. Similiarly, responses designed for Slack using `|` within a `<>` to define linked text, such as ```{"text": "Click for a link."}```, are also translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack + +To see samples and community contributions, please visit . From 9dc6308f1c8b05ba905257c206fe7d75eb6743c0 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 23 Oct 2015 10:37:45 -0400 Subject: [PATCH 35/65] Minor fixes to the doc --- doc/integrations/webhooks/Outgoing-Webhooks.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/integrations/webhooks/Outgoing-Webhooks.md b/doc/integrations/webhooks/Outgoing-Webhooks.md index 627fe1298c..93f26eb98b 100644 --- a/doc/integrations/webhooks/Outgoing-Webhooks.md +++ b/doc/integrations/webhooks/Outgoing-Webhooks.md @@ -71,7 +71,6 @@ If you'd like to build your own integration that uses outgoing webhooks, you can Host: localhost:5000 Accept: application/json Content-Type: application/x-www-form-urlencoded - Accept-Encoding: gzip channel_id=hawos4dqtby53pd64o4a4cmeoo& channel_name=town-square& @@ -88,7 +87,7 @@ If you'd like to build your own integration that uses outgoing webhooks, you can 3. If you want your integration to post a message back to the same channel, it can respond to the HTTP POST request from Mattermost with a JSON response body similar to this example: ``` { - "text": "This some response text." + "text": "This is some response text." } ``` 3. Set up your integration running on Heroku, an AWS server or a server of your own to start getting real time post events from Mattermost channels From f6a660cf4a544d242c6aecc5ec51d4f7bdd4fc8b Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 26 Oct 2015 07:53:11 -0400 Subject: [PATCH 36/65] Minor updates to outgoing webhook doc --- doc/integrations/webhooks/Outgoing-Webhooks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/integrations/webhooks/Outgoing-Webhooks.md b/doc/integrations/webhooks/Outgoing-Webhooks.md index 93f26eb98b..9cfde9606d 100644 --- a/doc/integrations/webhooks/Outgoing-Webhooks.md +++ b/doc/integrations/webhooks/Outgoing-Webhooks.md @@ -9,7 +9,7 @@ A couple key points: _Example:_ -Suppose you had an external application that recieved a post event asking for the status of a daily build. The application could respond with a table of total tests run and total tests failed by component category, with links to failed tests by category. An example response the application could respond with would be: +Suppose you had an external application that recieved a post event whenever a message starting with `#build`. If a user posted the message `#build Let's see the status`, then the external application would receive an HTTP POST with data about that message. The application could then respond with a table of total tests run and total tests failed by component category, with links to failed tests by category. An example response might be: ``` {"text": " --- @@ -45,7 +45,7 @@ Outgoing webhooks should be enabled on your Mattermost instance by default, but Once outgoing webhooks are enabled, you will be able to set one up through the Mattermost UI. You will need to know the following 1. The channel (if not all of them) you want to listen to post events from -2. The trigger words (if any) at the beginning of the post will trigger a post event +2. The trigger words (if any) that will trigger a post event if they are the **first word** of the post 3. The URL you want Mattermost to report the events to Once you have those, you can follow these steps to set up your webhook: From 30a8182869e30e531ce3dd2f87322c86aab0d749 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 26 Oct 2015 07:56:34 -0400 Subject: [PATCH 37/65] Added limitations section to outgoing webhook doc --- doc/integrations/webhooks/Incoming-Webhooks.md | 1 + doc/integrations/webhooks/Outgoing-Webhooks.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md index b040dc8474..1216cb5dbf 100644 --- a/doc/integrations/webhooks/Incoming-Webhooks.md +++ b/doc/integrations/webhooks/Incoming-Webhooks.md @@ -94,3 +94,4 @@ To see samples and community contributions, please visit ` to define linked text, such as ```{"text": "Click for a link."}```, are also translated to the equivalent markdown in Mattermost and rendered the same as you would see in Slack To see samples and community contributions, please visit . + +#### Limitations + +- Overriding of usernames does not yet apply to notifications +- Cannot supply `icon_emoji` to override the message icon From 93887cb79d75db4fb344a07a96e8802314fe6fea Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 06:20:41 -0700 Subject: [PATCH 38/65] Adding request for testing help --- doc/install/Production-Debian.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/install/Production-Debian.md b/doc/install/Production-Debian.md index 03e5e494f2..e97f3188b9 100644 --- a/doc/install/Production-Debian.md +++ b/doc/install/Production-Debian.md @@ -1,4 +1,7 @@ -# Production Installation on Debian Jessie (x64) +# (Community Guide) Production Installation on Debian Jessie (x64) + +Note: This install guide has been generously contributed by the Mattermost community. It has not yet been tested by the core. We have [an open ticket](https://github.com/mattermost/platform/issues/1185) requesting community help testing and improving this guide. Once the community has confirmed we have multiple deployments on these instructions, we can update the text here. If you're installing on Debian anyway, please let us know any issues or instruciton improvements? https://github.com/mattermost/platform/issues/1185 + ## Install Debian Jessie (x64) 1. Set up 3 machines with Debian Jessie with 2GB of RAM or more. The servers will be used for the Load Balancer, Mattermost (this must be x64 to use pre-built binaries), and Database. From 35e80125bb11459a1fd2480c2bd2781147294c84 Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 06:47:24 -0700 Subject: [PATCH 39/65] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eb273b7ecd..00a01c2d36 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Please see the [features pages of the Mattermost website](http://www.mattermost. - [Feature Ideas Forum](http://www.mattermost.org/feature-requests/) - For sharing ideas for future versions - [Contribution Guidelines](http://www.mattermost.org/contribute-to-mattermost/) - For contributing code or feedback to the project -Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq). +Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq), or talk to the core team on our [daily builds server](https://pre-release.mattermost.com/core) via [this invite link](https://pre-release.mattermost.com/signup_user_complete/?id=rcgiyftm7jyrxnma1osd8zswby). You can also join us via a [community maintained IRC bridge](https://github.com/42wim/matterbridge) available from `#matterbridge` on irc.freenode.net. ## Installing Mattermost From 845b07ea5f3214c4dc939b7eb65dcfba4e1e0b2a Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 06:48:29 -0700 Subject: [PATCH 40/65] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00a01c2d36..2585544e4c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Please see the [features pages of the Mattermost website](http://www.mattermost. - [Feature Ideas Forum](http://www.mattermost.org/feature-requests/) - For sharing ideas for future versions - [Contribution Guidelines](http://www.mattermost.org/contribute-to-mattermost/) - For contributing code or feedback to the project -Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq), or talk to the core team on our [daily builds server](https://pre-release.mattermost.com/core) via [this invite link](https://pre-release.mattermost.com/signup_user_complete/?id=rcgiyftm7jyrxnma1osd8zswby). You can also join us via a [community maintained IRC bridge](https://github.com/42wim/matterbridge) available from `#matterbridge` on irc.freenode.net. +Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq), or talk to the core team on our [daily builds server](https://pre-release.mattermost.com/core) via [this invite link](https://pre-release.mattermost.com/signup_user_complete/?id=rcgiyftm7jyrxnma1osd8zswby). ## Installing Mattermost From e266599c34790975b33d3a57273b9cbe764424bc Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Mon, 26 Oct 2015 09:57:42 -0400 Subject: [PATCH 41/65] Adding more secure nginx instructions --- doc/install/Production-Ubuntu.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md index 836af3995f..2e02cca385 100644 --- a/doc/install/Production-Ubuntu.md +++ b/doc/install/Production-Ubuntu.md @@ -119,7 +119,7 @@ exec bin/platform ## Set up Nginx with SSL (Recommended) 1. You will need a SSL cert from a certificate authority. -1. For simplicity we will generate a test certificate. +2. For simplicity we will generate a test certificate. * ``` mkdir ~/cert``` * ``` cd ~/cert``` * ``` sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout mattermost.key -out mattermost.crt``` @@ -133,8 +133,8 @@ exec bin/platform Common Name (e.g. server FQDN or YOUR name) []:mattermost.example.com Email Address []:admin@mattermost.example.com ``` -1. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines - * +3. Run `openssl dhparam -out dhparam.pem 4096` (it will take some time). +4. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines: ``` server { listen 80; @@ -149,9 +149,10 @@ exec bin/platform ssl on; ssl_certificate /home/ubuntu/cert/mattermost.crt; ssl_certificate_key /home/ubuntu/cert/mattermost.key; + ssl_dhparam /home/ubuntu/cert/dhparam.pem; ssl_session_timeout 5m; - ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; - ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES"; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH'; ssl_prefer_server_ciphers on; # add to location / above From 663bec814767fa9c92e7ab2c706c0fe4c0432cf1 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 26 Oct 2015 11:45:03 -0400 Subject: [PATCH 42/65] Moved logic for searching for posts by multiple users/channels into the sql query --- api/post_test.go | 3 ++ model/search_params.go | 67 +++++++++++++++++------------------------ store/sql_post_store.go | 52 ++++++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/api/post_test.go b/api/post_test.go index 3df622d842..e54e9ef0c8 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -546,6 +546,9 @@ func TestSearchPostsFromUser(t *testing.T) { Client.Must(Client.JoinChannel(channel1.Id)) Client.Must(Client.JoinChannel(channel2.Id)) + // wait for the join/leave messages to be created for user3 since they're done asynchronously + time.Sleep(100 * time.Millisecond) + if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 { t.Fatalf("wrong number of posts returned %v", len(result.Order)) } diff --git a/model/search_params.go b/model/search_params.go index 6b665f5a03..144e8e461a 100644 --- a/model/search_params.go +++ b/model/search_params.go @@ -8,10 +8,10 @@ import ( ) type SearchParams struct { - Terms string - IsHashtag bool - InChannel string - FromUser string + Terms string + IsHashtag bool + InChannels []string + FromUsers []string } var searchFlags = [...]string{"from", "channel", "in"} @@ -106,45 +106,34 @@ func ParseSearchParams(text string) []*SearchParams { } } - if len(inChannels) == 0 { - inChannels = append(inChannels, "") - } - if len(fromUsers) == 0 { - fromUsers = append(fromUsers, "") - } - paramsList := []*SearchParams{} - for _, inChannel := range inChannels { - for _, fromUser := range fromUsers { - if len(plainTerms) > 0 { - paramsList = append(paramsList, &SearchParams{ - Terms: plainTerms, - IsHashtag: false, - InChannel: inChannel, - FromUser: fromUser, - }) - } + if len(plainTerms) > 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: plainTerms, + IsHashtag: false, + InChannels: inChannels, + FromUsers: fromUsers, + }) + } - if len(hashtagTerms) > 0 { - paramsList = append(paramsList, &SearchParams{ - Terms: hashtagTerms, - IsHashtag: true, - InChannel: inChannel, - FromUser: fromUser, - }) - } + if len(hashtagTerms) > 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: hashtagTerms, + IsHashtag: true, + InChannels: inChannels, + FromUsers: fromUsers, + }) + } - // special case for when no terms are specified but we still have a filter - if len(plainTerms) == 0 && len(hashtagTerms) == 0 { - paramsList = append(paramsList, &SearchParams{ - Terms: "", - IsHashtag: true, - InChannel: inChannel, - FromUser: fromUser, - }) - } - } + // special case for when no terms are specified but we still have a filter + if len(plainTerms) == 0 && len(hashtagTerms) == 0 { + paramsList = append(paramsList, &SearchParams{ + Terms: "", + IsHashtag: true, + InChannels: inChannels, + FromUsers: fromUsers, + }) } return paramsList diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 6971de9d7e..ea913ab6a4 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -6,6 +6,7 @@ package store import ( "fmt" "regexp" + "strconv" "strings" "github.com/mattermost/platform/model" @@ -413,10 +414,15 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP go func() { result := StoreResult{} + queryParams := map[string]interface{}{ + "TeamId": teamId, + "UserId": userId, + } + termMap := map[string]bool{} terms := params.Terms - if terms == "" && params.InChannel == "" && params.FromUser == "" { + if terms == "" && len(params.InChannels) == 0 && len(params.FromUsers) == 0 { result.Data = []*model.Post{} storeChannel <- result return @@ -468,13 +474,45 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP ORDER BY CreateAt DESC LIMIT 100` - if params.InChannel != "" { + if len(params.InChannels) > 1 { + inClause := ":InChannel0" + queryParams["InChannel0"] = params.InChannels[0] + + for i := 1; i < len(params.InChannels); i++ { + paramName := "InChannel" + strconv.FormatInt(int64(i), 10) + inClause += ", :" + paramName + queryParams[paramName] = params.InChannels[i] + } + + searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name IN ("+inClause+")", 1) + } else if len(params.InChannels) == 1 { + queryParams["InChannel"] = params.InChannels[0] searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name = :InChannel", 1) } else { searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "", 1) } - if params.FromUser != "" { + if len(params.FromUsers) > 1 { + inClause := ":FromUser0" + queryParams["FromUser0"] = params.FromUsers[0] + + for i := 1; i < len(params.FromUsers); i++ { + paramName := "FromUser" + strconv.FormatInt(int64(i), 10) + inClause += ", :" + paramName + queryParams[paramName] = params.FromUsers[i] + } + + searchQuery = strings.Replace(searchQuery, "POST_FILTER", ` + AND UserId IN ( + SELECT + Id + FROM + Users + WHERE + TeamId = :TeamId + AND Username IN (`+inClause+`))`, 1) + } else if len(params.FromUsers) == 1 { + queryParams["FromUser"] = params.FromUsers[0] searchQuery = strings.Replace(searchQuery, "POST_FILTER", ` AND UserId IN ( SELECT @@ -506,13 +544,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) } - queryParams := map[string]interface{}{ - "TeamId": teamId, - "UserId": userId, - "Terms": terms, - "InChannel": params.InChannel, - "FromUser": params.FromUser, - } + queryParams["Terms"] = terms _, err := s.GetReplica().Select(&posts, searchQuery, queryParams) if err != nil { From af21b0bcb10bbe4d1a07d0a62a102b0e1f181eaf Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 08:59:35 -0700 Subject: [PATCH 43/65] Adding more GitLab 8.1 instructions --- doc/install/Upgrade-Guide.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/doc/install/Upgrade-Guide.md b/doc/install/Upgrade-Guide.md index cecd45353f..007636e7df 100644 --- a/doc/install/Upgrade-Guide.md +++ b/doc/install/Upgrade-Guide.md @@ -1,10 +1,23 @@ # Mattermost Upgrade Guide -### Upgrading Mattermost v0.7 to v1.1.1 +### Upgrading Mattermost in GitLab 8.0 to GitLab 8.1 with omnibus -_Note: [Mattermost v1.1.1](https://github.com/mattermost/platform/releases/tag/v1.1.1) is a special release of Mattermost v1.1 that upgrades the database to Mattermost v1.1 from EITHER Mattermost v0.7 or Mattermost v1.0. The following instructions are for upgrading from Mattermost v0.7 to v1.1.1 and skipping the upgrade to Mattermost v1.0._ +Mattermost 0.7.1-beta in GitLab 8.0 was a pre-release of Mattermost and Mattermost v1.1.1 in GitLab 8.1 was [updated significantly](https://github.com/mattermost/platform/blob/master/CHANGELOG.md#configjson-changes-from-v07-to-v10) to get to a stable, forwards-compatible platform for Mattermost. -If you've manually changed Mattermost v0.7 configuration by updating the `config.json` file, you'll need to port those changes to Mattermost v1.1.1: +The Mattermost team didn't think it made sense for GitLab omnibus to attempt an automated re-configuration of Mattermost (since 0.7.1-beta was a pre-release) given the scale of change, so we're providing instructions for GitLab users who have customized their Mattermost deployments in 8.0 to move to 8.1: + +1. Follow the Upgrading Mattermost v0.7.1-beta to v1.1.1 instructions below to identify the settings in Mattermost's `config.json` file that differ from defaults and need to be updated from GitLab 8.0 to 8.1. +2. Upgrade to GitLab 8.1 using omnibus, and allowing it overwrite `config.json` to the new Mattermost v1.1.1 format +3. Manually update `config.json` to new settings identified in Step 1. + +Optionally, you can use the new [System Console user interface](https://github.com/mattermost/platform/blob/master/doc/install/Configuration-Settings.md) to make changes to your new `config.json` file. + + +### Upgrading Mattermost v0.7.1-beta to v1.1.1 + +_Note: [Mattermost v1.1.1](https://github.com/mattermost/platform/releases/tag/v1.1.1) is a special release of Mattermost v1.1 that upgrades the database to Mattermost v1.1 from EITHER Mattermost v0.7 or Mattermost v1.0. The following instructions are for upgrading from Mattermost v0.7.1-beta to v1.1.1 and skipping the upgrade to Mattermost v1.0._ + +If you've manually changed Mattermost v0.7.1-beta configuration by updating the `config.json` file, you'll need to port those changes to Mattermost v1.1.1: 1. Go to the `config.json` file that you manually updated and note any differences from the [default `config.json` file in Mattermost 0.7](https://github.com/mattermost/platform/blob/v0.7.0/config/config.json). @@ -16,3 +29,5 @@ Optionally, you can use the new [System Console user interface](https://github.c + + From c590b6404208c9aaab0c5ed8d51e8415661658d9 Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 09:00:03 -0700 Subject: [PATCH 44/65] Adding link --- doc/install/Upgrade-Guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/Upgrade-Guide.md b/doc/install/Upgrade-Guide.md index 007636e7df..fc3a0711f7 100644 --- a/doc/install/Upgrade-Guide.md +++ b/doc/install/Upgrade-Guide.md @@ -6,7 +6,7 @@ Mattermost 0.7.1-beta in GitLab 8.0 was a pre-release of Mattermost and Mattermo The Mattermost team didn't think it made sense for GitLab omnibus to attempt an automated re-configuration of Mattermost (since 0.7.1-beta was a pre-release) given the scale of change, so we're providing instructions for GitLab users who have customized their Mattermost deployments in 8.0 to move to 8.1: -1. Follow the Upgrading Mattermost v0.7.1-beta to v1.1.1 instructions below to identify the settings in Mattermost's `config.json` file that differ from defaults and need to be updated from GitLab 8.0 to 8.1. +1. Follow the [Upgrading Mattermost v0.7.1-beta to v1.1.1 instructions](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md#upgrading-mattermost-v071-beta-to-v111) below to identify the settings in Mattermost's `config.json` file that differ from defaults and need to be updated from GitLab 8.0 to 8.1. 2. Upgrade to GitLab 8.1 using omnibus, and allowing it overwrite `config.json` to the new Mattermost v1.1.1 format 3. Manually update `config.json` to new settings identified in Step 1. From fda62fbbf576ead8aea3b4a39a167b7f2d218142 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 26 Oct 2015 12:05:09 -0400 Subject: [PATCH 45/65] Fix error message on leaving channel --- web/react/stores/socket_store.jsx | 2 +- web/react/utils/async_client.jsx | 4 ++-- web/react/utils/constants.jsx | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 9410c1e9cc..d4b0e62dbf 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -160,7 +160,7 @@ function handleNewPostEvent(msg) { if (window.isActive) { AsyncClient.updateLastViewedAt(true); } - } else { + } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) { AsyncClient.getChannel(msg.channel_id); } diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index b1bc71d548..75dd35e3f1 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -132,7 +132,7 @@ export function getChannel(id) { callTracker['getChannel' + id] = utils.getTimestamp(); client.getChannel(id, - function getChannelSuccess(data, textStatus, xhr) { + (data, textStatus, xhr) => { callTracker['getChannel' + id] = 0; if (xhr.status === 304 || !data) { @@ -145,7 +145,7 @@ export function getChannel(id) { member: data.member }); }, - function getChannelFailure(err) { + (err) => { callTracker['getChannel' + id] = 0; dispatchError(err, 'getChannel'); } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index c20d84f403..f31bf6740b 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -98,6 +98,7 @@ module.exports = { POST_LOADING: 'loading', POST_FAILED: 'failed', POST_DELETED: 'deleted', + POST_TYPE_JOIN_LEAVE: 'join_leave', RESERVED_TEAM_NAMES: [ 'www', 'web', From 46042d995bc553a10513c527aba106e1b92d04d4 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 02:00:55 +0100 Subject: [PATCH 46/65] PLT-703: Support multiple users being shown as typing underneath input boxes. --- web/react/components/create_post.jsx | 2 +- web/react/components/msg_typing.jsx | 49 +++++++++++++++++++--------- web/react/utils/constants.jsx | 1 + 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 055be112db..b74f1871ca 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -208,7 +208,7 @@ export default class CreatePost extends React.Component { } const t = Date.now(); - if ((t - this.lastTime) > 5000) { + if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) { SocketStore.sendMessage({channel_id: this.state.channelId, action: 'typing', props: {parent_id: ''}, state: {}}); this.lastTime = t; } diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx index 1bd23c55c3..42ae4bcc47 100644 --- a/web/react/components/msg_typing.jsx +++ b/web/react/components/msg_typing.jsx @@ -11,11 +11,11 @@ export default class MsgTyping extends React.Component { constructor(props) { super(props); - this.timer = null; - this.lastTime = 0; - this.onChange = this.onChange.bind(this); + this.getTypingText = this.getTypingText.bind(this); + this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this); + this.typingUsers = {}; this.state = { text: '' }; @@ -27,7 +27,7 @@ export default class MsgTyping extends React.Component { componentWillReceiveProps(newProps) { if (this.props.channelId !== newProps.channelId) { - this.setState({text: ''}); + this.setState({text: this.getTypingText()}); } } @@ -36,27 +36,44 @@ export default class MsgTyping extends React.Component { } onChange(msg) { + let username = 'Someone'; if (msg.action === SocketEvents.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) { - this.timer = setInterval(function myTimer() { - if ((new Date().getTime() - this.lastTime) > 8000) { - this.setState({text: ''}); - } - }.bind(this), 3000); + if (this.typingUsers[username]) { + clearTimeout(this.typingUsers[username]); } + + this.typingUsers[username] = setTimeout(function myTimer(user) { + delete this.typingUsers[user]; + this.setState({text: this.getTypingText()}); + }.bind(this, username), Constants.UPDATE_TYPING_MS); + + this.setState({text: this.getTypingText()}); } else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) { - this.setState({text: ''}); + if (UserStore.hasProfile(msg.user_id)) { + username = UserStore.getProfile(msg.user_id).username; + } + clearTimeout(this.typingUsers[username]); + delete this.typingUsers[username]; + this.setState({text: this.getTypingText()}); + } + } + + getTypingText() { + let users = Object.keys(this.typingUsers); + switch (users.length) { + case 0: + return ''; + case 1: + return users[0] + ' is typing...'; + default: + const last = users.pop(); + return users.join(', ') + ' and ' + last + ' are typing...'; } } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index c20d84f403..cda04bf047 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -132,6 +132,7 @@ module.exports = { OFFLINE_ICON_SVG: "", MENU_ICON: " ", COMMENT_ICON: " ", + UPDATE_TYPING_MS: 5000, THEMES: { default: { type: 'Mattermost', From b7aa4220d575d446830a0b0c9a3f753214272422 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Mon, 26 Oct 2015 02:24:46 +0100 Subject: [PATCH 47/65] use constant value to check if to send a typing event for comments --- web/react/components/create_comment.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 435c7d5420..18936e8086 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -147,7 +147,7 @@ export default class CreateComment extends React.Component { } const t = Date.now(); - if ((t - this.lastTime) > 5000) { + if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) { SocketStore.sendMessage({channel_id: this.props.channelId, action: 'typing', props: {parent_id: this.props.rootId}}); this.lastTime = t; } From 9a4d648c9b9a6c78cd2a171b665bc5aa9ade3634 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Mon, 26 Oct 2015 17:44:29 +0100 Subject: [PATCH 48/65] rename getTypingText to updateTypingText and set component's state in it --- web/react/components/msg_typing.jsx | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx index 42ae4bcc47..ccf8a24455 100644 --- a/web/react/components/msg_typing.jsx +++ b/web/react/components/msg_typing.jsx @@ -12,7 +12,7 @@ export default class MsgTyping extends React.Component { super(props); this.onChange = this.onChange.bind(this); - this.getTypingText = this.getTypingText.bind(this); + this.updateTypingText = this.updateTypingText.bind(this); this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this); this.typingUsers = {}; @@ -27,7 +27,7 @@ export default class MsgTyping extends React.Component { componentWillReceiveProps(newProps) { if (this.props.channelId !== newProps.channelId) { - this.setState({text: this.getTypingText()}); + this.updateTypingText(); } } @@ -50,31 +50,37 @@ export default class MsgTyping extends React.Component { this.typingUsers[username] = setTimeout(function myTimer(user) { delete this.typingUsers[user]; - this.setState({text: this.getTypingText()}); + this.updateTypingText(); }.bind(this, username), Constants.UPDATE_TYPING_MS); - this.setState({text: this.getTypingText()}); + this.updateTypingText(); } else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) { if (UserStore.hasProfile(msg.user_id)) { username = UserStore.getProfile(msg.user_id).username; } clearTimeout(this.typingUsers[username]); delete this.typingUsers[username]; - this.setState({text: this.getTypingText()}); + this.updateTypingText(); } } - getTypingText() { - let users = Object.keys(this.typingUsers); + updateTypingText() { + const users = Object.keys(this.typingUsers); + let text = ''; switch (users.length) { case 0: - return ''; + text = ''; + break; case 1: - return users[0] + ' is typing...'; + text = users[0] + ' is typing...'; + break; default: const last = users.pop(); - return users.join(', ') + ' and ' + last + ' are typing...'; + text = users.join(', ') + ' and ' + last + ' are typing...'; + break; } + + this.setState({text}); } render() { From a9ec7215ade2b64f2d5164a12b47d92ed8981233 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 15:12:36 +0100 Subject: [PATCH 49/65] PLT-642: Browser tab alerts --- web/react/components/sidebar.jsx | 61 +++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index ed2c84057f..0d3577f2d5 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -40,6 +40,9 @@ export default class Sidebar extends React.Component { this.hideMoreDirectChannelsModal = this.hideMoreDirectChannelsModal.bind(this); this.createChannelElement = this.createChannelElement.bind(this); + this.updateTitle = this.updateTitle.bind(this); + this.setUnreadCount = this.setUnreadCount.bind(this); + this.getUnreadCount = this.getUnreadCount.bind(this); this.isLeaving = new Map(); @@ -49,8 +52,44 @@ export default class Sidebar extends React.Component { state.loadingDMChannel = -1; state.windowWidth = Utils.windowWidth(); + this.unreadCount = this.setUnreadCount(); this.state = state; } + setUnreadCount() { + const channels = ChannelStore.getAll(); + const members = ChannelStore.getAllMembers(); + const unreadCount = {}; + + channels.forEach((ch) => { + const chMember = members[ch.id]; + let chMentionCount = chMember.mention_count; + let chUnreadCount = ch.total_msg_count - chMember.msg_count - chMentionCount; + + if (ch.type === 'D') { + chMentionCount = chUnreadCount; + chUnreadCount = 0; + } + + unreadCount[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount}; + }); + + return unreadCount; + } + getUnreadCount(channelId) { + let mentions = 0; + let msgs = 0; + + if (channelId) { + return this.unreadCount[channelId] ? this.unreadCount[channelId] : {msgs, mentions}; + } + + Object.keys(this.unreadCount).forEach((chId) => { + msgs += this.unreadCount[chId].msgs; + mentions += this.unreadCount[chId].mentions; + }); + + return {msgs, mentions}; + } getStateFromStores() { const members = ChannelStore.getAllMembers(); var teamMemberMap = UserStore.getActiveOnlyProfiles(); @@ -192,7 +231,10 @@ export default class Sidebar extends React.Component { currentChannelName = Utils.getDirectTeammate(channel.id).username; } - document.title = currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName; + const unread = this.getUnreadCount(); + const mentionTitle = unread.mentions > 0 ? '(' + unread.mentions + ') ' : ''; + const unreadTitle = unread.msgs > 0 ? '* ' : ''; + document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName; } } onScroll() { @@ -273,6 +315,7 @@ export default class Sidebar extends React.Component { var members = this.state.members; var activeId = this.state.activeId; var channelMember = members[channel.id]; + var unreadCount = this.getUnreadCount(channel.id); var msgCount; var linkClass = ''; @@ -284,7 +327,7 @@ export default class Sidebar extends React.Component { var unread = false; if (channelMember) { - msgCount = channel.total_msg_count - channelMember.msg_count; + msgCount = unreadCount.msgs + unreadCount.mentions; unread = (msgCount > 0 && channelMember.notify_props.mark_unread !== 'mention') || channelMember.mention_count > 0; } @@ -301,16 +344,8 @@ export default class Sidebar extends React.Component { var badge = null; if (channelMember) { - if (channel.type === 'D') { - // direct message channels show badges for any number of unread posts - msgCount = channel.total_msg_count - channelMember.msg_count; - if (msgCount > 0) { - badge = {msgCount}; - this.badgesActive = true; - } - } else if (channelMember.mention_count > 0) { - // public and private channels only show badges for mentions - badge = {channelMember.mention_count}; + if (unreadCount.mentions) { + badge = {unreadCount.mentions}; this.badgesActive = true; } } else if (this.state.loadingDMChannel === index && channel.type === 'D') { @@ -434,6 +469,8 @@ export default class Sidebar extends React.Component { render() { this.badgesActive = false; + this.unreadCount = this.setUnreadCount(); + // keep track of the first and last unread channels so we can use them to set the unread indicators this.firstUnreadChannel = null; this.lastUnreadChannel = null; From a88fe8ea1a9cc020cc0d1dc442d46fc0bed90cc9 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Mon, 26 Oct 2015 17:53:21 +0100 Subject: [PATCH 50/65] rename methods and varibles to a more meaningful name --- web/react/components/sidebar.jsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 0d3577f2d5..5cb6d168b9 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -41,7 +41,7 @@ export default class Sidebar extends React.Component { this.createChannelElement = this.createChannelElement.bind(this); this.updateTitle = this.updateTitle.bind(this); - this.setUnreadCount = this.setUnreadCount.bind(this); + this.setUnreadCountPerChannel = this.setUnreadCountPerChannel.bind(this); this.getUnreadCount = this.getUnreadCount.bind(this); this.isLeaving = new Map(); @@ -51,14 +51,15 @@ export default class Sidebar extends React.Component { state.showDirectChannelsModal = false; state.loadingDMChannel = -1; state.windowWidth = Utils.windowWidth(); - - this.unreadCount = this.setUnreadCount(); this.state = state; + + this.unreadCountPerChannel = {}; + this.setUnreadCountPerChannel(); } - setUnreadCount() { + setUnreadCountPerChannel() { const channels = ChannelStore.getAll(); const members = ChannelStore.getAllMembers(); - const unreadCount = {}; + const channelUnreadCounts = {}; channels.forEach((ch) => { const chMember = members[ch.id]; @@ -70,22 +71,22 @@ export default class Sidebar extends React.Component { chUnreadCount = 0; } - unreadCount[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount}; + channelUnreadCounts[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount}; }); - return unreadCount; + this.unreadCountPerChannel = channelUnreadCounts; } getUnreadCount(channelId) { let mentions = 0; let msgs = 0; if (channelId) { - return this.unreadCount[channelId] ? this.unreadCount[channelId] : {msgs, mentions}; + return this.unreadCountPerChannel[channelId] ? this.unreadCountPerChannel[channelId] : {msgs, mentions}; } - Object.keys(this.unreadCount).forEach((chId) => { - msgs += this.unreadCount[chId].msgs; - mentions += this.unreadCount[chId].mentions; + Object.keys(this.unreadCountPerChannel).forEach((chId) => { + msgs += this.unreadCountPerChannel[chId].msgs; + mentions += this.unreadCountPerChannel[chId].mentions; }); return {msgs, mentions}; @@ -469,7 +470,7 @@ export default class Sidebar extends React.Component { render() { this.badgesActive = false; - this.unreadCount = this.setUnreadCount(); + this.setUnreadCountPerChannel(); // keep track of the first and last unread channels so we can use them to set the unread indicators this.firstUnreadChannel = null; From 6d957a26d884cd2ff200a4d14b035bc03415cab6 Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Mon, 26 Oct 2015 10:42:54 -0700 Subject: [PATCH 51/65] PLT-564 removing auto-hide from error bar --- web/react/components/error_bar.jsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx index 6311d9460c..f098384aa7 100644 --- a/web/react/components/error_bar.jsx +++ b/web/react/components/error_bar.jsx @@ -9,12 +9,8 @@ export default class ErrorBar extends React.Component { this.onErrorChange = this.onErrorChange.bind(this); this.handleClose = this.handleClose.bind(this); - this.prevTimer = null; this.state = ErrorStore.getLastError(); - if (this.isValidError(this.state)) { - this.prevTimer = setTimeout(this.handleClose, 10000); - } } isValidError(s) { @@ -56,16 +52,8 @@ export default class ErrorBar extends React.Component { onErrorChange() { var newState = ErrorStore.getLastError(); - if (this.prevTimer != null) { - clearInterval(this.prevTimer); - this.prevTimer = null; - } - if (newState) { this.setState(newState); - if (!this.isConnectionError(newState)) { - this.prevTimer = setTimeout(this.handleClose, 10000); - } } else { this.setState({message: null}); } From 05b16f40dffe870f0a119942b5d46b8a8f4c0753 Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Mon, 26 Oct 2015 11:00:34 -0700 Subject: [PATCH 52/65] PLT-828 fixing db upgrade code --- store/sql_channel_store.go | 3 +-- store/sql_store.go | 13 +++++++++---- store/sql_team_store.go | 2 +- store/sql_user_store.go | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index 8bedf0632b..07e8e01518 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -40,7 +40,7 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore { func (s SqlChannelStore) UpgradeSchemaIfNeeded() { - // BEGIN REMOVE AFTER 1.1.0 + // REMOVE AFTER 1.2 SHIP see PLT-828 if s.CreateColumnIfNotExists("ChannelMembers", "NotifyProps", "varchar(2000)", "varchar(2000)", "{}") { // populate NotifyProps from existing NotifyLevel field @@ -83,7 +83,6 @@ func (s SqlChannelStore) UpgradeSchemaIfNeeded() { s.RemoveColumnIfExists("ChannelMembers", "NotifyLevel") } - // END REMOVE AFTER 1.1.0 } func (s SqlChannelStore) CreateIndexesIfNotExists() { diff --git a/store/sql_store.go b/store/sql_store.go index 0d1bfe41b2..d5c84d5222 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -73,7 +73,8 @@ func NewSqlStore() Store { } schemaVersion := sqlStore.GetCurrentSchemaVersion() - isSchemaVersion07 := false + isSchemaVersion07 := false // REMOVE AFTER 1.2 SHIP see PLT-828 + isSchemaVersion10 := false // REMOVE AFTER 1.2 SHIP see PLT-828 // If the version is already set then we are potentially in an 'upgrade needed' state if schemaVersion != "" { @@ -86,7 +87,11 @@ func NewSqlStore() Store { isSchemaVersion07 = true } - if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 { + if schemaVersion == "1.0.0" { + isSchemaVersion10 = true + } + + if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 { l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date") l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion) } else { @@ -98,7 +103,7 @@ func NewSqlStore() Store { } } - // REMOVE in 1.2 + // REMOVE AFTER 1.2 SHIP see PLT-828 if sqlStore.DoesTableExist("Sessions") { if sqlStore.DoesColumnExist("Sessions", "AltId") { sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions") @@ -140,7 +145,7 @@ func NewSqlStore() Store { sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() - if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 { + if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 { sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion) } diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 380d979bd3..8700a9d04d 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -29,7 +29,7 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore { } func (s SqlTeamStore) UpgradeSchemaIfNeeded() { - // REMOVE in 1.2 + // REMOVE AFTER 1.2 SHIP see PLT-828 s.RemoveColumnIfExists("Teams", "AllowValet") } diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 5fab38acea..d825cda57d 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -41,7 +41,7 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore { } func (us SqlUserStore) UpgradeSchemaIfNeeded() { - // REMOVE in 1.2 + // REMOVE AFTER 1.2 SHIP see PLT-828 us.CreateColumnIfNotExists("Users", "ThemeProps", "varchar(2000)", "character varying(2000)", "{}") } From ff6e91f51d844a4703d3c4648b8b6bffe0cdabbc Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 26 Oct 2015 14:15:07 -0400 Subject: [PATCH 53/65] Fixed file upload and profile picture upload error display to work for serverside errors --- web/react/components/create_post.jsx | 10 ++++++++-- .../components/user_settings/user_settings_general.jsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 055be112db..0867bfdf20 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -253,8 +253,14 @@ export default class CreatePost extends React.Component { this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews}); } handleUploadError(err, clientId) { + let message = err; + if (message && typeof message !== 'string') { + // err is an AppError from the server + message = err.message; + } + if (clientId === -1) { - this.setState({serverError: err}); + this.setState({serverError: message}); } else { const draft = PostStore.getDraft(this.state.channelId); @@ -265,7 +271,7 @@ export default class CreatePost extends React.Component { PostStore.storeDraft(this.state.channelId, draft); - this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: err}); + this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: message}); } } handleTextDrop(text) { diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 70e559c304..1c8ce3c795 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -171,7 +171,7 @@ export default class UserSettingsGeneralTab extends React.Component { }.bind(this), function imageUploadFailure(err) { var state = this.setupInitialState(this.props); - state.serverError = err; + state.serverError = err.message; this.setState(state); }.bind(this) ); From 9635bfdd4f3925b0f6f0873508216824b604ac10 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 26 Oct 2015 14:38:46 -0400 Subject: [PATCH 54/65] Prevented image files larger than 4k resolution from being uploaded --- api/file.go | 22 +++++++++++++++++----- api/user.go | 12 ++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/api/file.go b/api/file.go index 94eea516ad..f65be145dd 100644 --- a/api/file.go +++ b/api/file.go @@ -52,6 +52,8 @@ const ( RotatedCCW = 6 RotatedCCWMirrored = 7 RotatedCW = 8 + + MaxImageSize = 4096 * 2160 // 4k resolution ) var fileInfoCache *utils.Cache = utils.NewLru(1000) @@ -125,6 +127,21 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { uid := model.NewId() + if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { + imageNameList = append(imageNameList, uid+"/"+filename) + imageDataList = append(imageDataList, buf.Bytes()) + + // Decode image config first to check dimensions before loading the whole thing into memory later on + config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes())) + if err != nil { + c.Err = model.NewAppError("uploadFile", "Unable to upload image file.", err.Error()) + return + } else if config.Width*config.Height > MaxImageSize { + c.Err = model.NewAppError("uploadFile", "Unable to upload image file. File is too large.", err.Error()) + return + } + } + path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename if err := writeFile(buf.Bytes(), path); err != nil { @@ -132,11 +149,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { return } - if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { - imageNameList = append(imageNameList, uid+"/"+filename) - imageDataList = append(imageDataList, buf.Bytes()) - } - encName := utils.UrlEncode(filename) fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName diff --git a/api/user.go b/api/user.go index 06e5336f12..3796a50eee 100644 --- a/api/user.go +++ b/api/user.go @@ -855,6 +855,18 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { return } + // Decode image config first to check dimensions before loading the whole thing into memory later on + config, _, err := image.DecodeConfig(file) + if err != nil { + c.Err = model.NewAppError("uploadProfileFile", "Could not decode profile image config.", err.Error()) + return + } else if config.Width*config.Height > MaxImageSize { + c.Err = model.NewAppError("uploadProfileFile", "Unable to upload profile image. File is too large.", err.Error()) + return + } + + file.Seek(0, 0) + // Decode image into Image object img, _, err := image.Decode(file) if err != nil { From c94388042b684ae3c552f97505fdb67903db20ba Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 26 Oct 2015 15:10:17 -0400 Subject: [PATCH 55/65] Parse incoming webhook requsets into model instead of string map --- model/incoming_webhook.go | 19 +++++++++++++++++++ web/web.go | 14 +++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go index 9b9969b968..35861c0e06 100644 --- a/model/incoming_webhook.go +++ b/model/incoming_webhook.go @@ -23,6 +23,14 @@ type IncomingWebhook struct { TeamId string `json:"team_id"` } +type IncomingWebhookRequest struct { + Text string `json:"text"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + IconEmoji string `json:"icon_emoji"` + ChannelName string `json:"channel"` +} + func (o *IncomingWebhook) ToJson() string { b, err := json.Marshal(o) if err != nil { @@ -104,3 +112,14 @@ func (o *IncomingWebhook) PreSave() { func (o *IncomingWebhook) PreUpdate() { o.UpdateAt = GetMillis() } + +func IncomingWebhookRequestFromJson(data io.Reader) *IncomingWebhookRequest { + decoder := json.NewDecoder(data) + var o IncomingWebhookRequest + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/web/web.go b/web/web.go index 5f290ec995..bffe4858e8 100644 --- a/web/web.go +++ b/web/web.go @@ -969,20 +969,20 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { r.ParseForm() - var props map[string]string + var parsedRequest *model.IncomingWebhookRequest if r.Header.Get("Content-Type") == "application/json" { - props = model.MapFromJson(r.Body) + parsedRequest = model.IncomingWebhookRequestFromJson(r.Body) } else { - props = model.MapFromJson(strings.NewReader(r.FormValue("payload"))) + parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload"))) } - text := props["text"] + text := parsedRequest.Text if len(text) == 0 { c.Err = model.NewAppError("incomingWebhook", "No text specified", "") return } - channelName := props["channel"] + channelName := parsedRequest.ChannelName var hook *model.IncomingWebhook if result := <-hchan; result.Err != nil { @@ -1012,8 +1012,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { cchan = api.Srv.Store.Channel().Get(hook.ChannelId) } - overrideUsername := props["username"] - overrideIconUrl := props["icon_url"] + overrideUsername := parsedRequest.Username + overrideIconUrl := parsedRequest.IconURL if result := <-cchan; result.Err != nil { c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message) From 12399dd43c0ba57a879e4383f13a0fd4ed3350ae Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 26 Oct 2015 15:11:06 -0400 Subject: [PATCH 56/65] Remove unused iconemoji field --- model/incoming_webhook.go | 1 - 1 file changed, 1 deletion(-) diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go index 35861c0e06..be19842449 100644 --- a/model/incoming_webhook.go +++ b/model/incoming_webhook.go @@ -27,7 +27,6 @@ type IncomingWebhookRequest struct { Text string `json:"text"` Username string `json:"username"` IconURL string `json:"icon_url"` - IconEmoji string `json:"icon_emoji"` ChannelName string `json:"channel"` } From 1bb407721586169e7ca8f34f58edb44c51248a28 Mon Sep 17 00:00:00 2001 From: it33 Date: Mon, 26 Oct 2015 14:30:24 -0700 Subject: [PATCH 57/65] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 2585544e4c..75db2cdcec 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ Joining the Mattermost community is a great way to build relationships with othe - Review the [Mattermost Code Contribution Guidelines](http://docs.mattermost.org/developer/Code-Contribution-Guidelines/index.html) to submit patches for the core product - Consider building tools that help developers and IT professionals manage Mattermost more effectively (API documentation coming in Beta2) +##### Check out some projects for connecting to Mattermost: + +- [Matterbridge](https://github.com/42wim/matterbridge) - an IRC bridge connecting to Mattermost +- [GitLab Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-gitlab) - connecting GitLab to Mattermost via incoming webhooks +- [Giphy Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-giphy) - connecting Mattermost to Giphy via outgoing webhooks + #### Have other ideas or suggestions? If there’s some other way you’d like to contribute, please contact us at info@mattermost.com. We’d love to meet you! From c753eacad0ef403006a733b0aa73ac46234aec3e Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Sun, 25 Oct 2015 17:46:28 +0100 Subject: [PATCH 58/65] Add some to channel member popover --- web/react/components/popover_list_members.jsx | 134 +++++++++++++++++- web/sass-files/sass/partials/_popover.scss | 33 +++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 155e886001..b7d4fd9a9c 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -4,8 +4,24 @@ var UserStore = require('../stores/user_store.jsx'); var Popover = ReactBootstrap.Popover; var OverlayTrigger = ReactBootstrap.OverlayTrigger; +const Utils = require('../utils/utils.jsx'); + +const ChannelStore = require('../stores/channel_store.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); +const Client = require('../utils/client.jsx'); +const TeamStore = require('../stores/team_store.jsx'); + +const Constants = require('../utils/constants.jsx'); export default class PopoverListMembers extends React.Component { + constructor(props) { + super(props); + + this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); + this.closePopover = this.closePopover.bind(this); + } + componentDidMount() { const originalLeave = $.fn.popover.Constructor.prototype.leave; $.fn.popover.Constructor.prototype.leave = function onLeave(obj) { @@ -27,12 +43,62 @@ export default class PopoverListMembers extends React.Component { } }; } + + handleShowDirectChannel(teammate, e) { + e.preventDefault(); + + const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id); + let channel = ChannelStore.getByName(channelName); + + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); + AsyncClient.savePreferences([preference]); + + if (channel) { + Utils.switchChannel(channel); + this.closePopover(); + } else { + channel = { + name: channelName, + last_post_at: 0, + total_msg_count: 0, + type: 'D', + display_name: teammate.username, + teammate_id: teammate.id, + status: UserStore.getStatus(teammate.id) + }; + + Client.createDirectChannel( + channel, + teammate.id, + (data) => { + AsyncClient.getChannel(data.id); + Utils.switchChannel(data); + + this.closePopover(); + }, + () => { + window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; + this.closePopover(); + } + ); + } + } + + closePopover() { + var overlay = this.refs.overlay; + if (overlay.state.isOverlayShown) { + overlay.setState({isOverlayShown: false}); + } + } + render() { let popoverHtml = []; let count = 0; let countText = '-'; const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); + const currentUserId = UserStore.getCurrentId(); + const ch = ChannelStore.getCurrent(); if (members && teamMembers) { members.sort((a, b) => { @@ -40,13 +106,74 @@ export default class PopoverListMembers extends React.Component { }); members.forEach((m, i) => { + const details = []; + + const fullName = Utils.getFullName(m); + if (fullName) { + details.push( + + {fullName} + + ); + } + + if (m.nickname) { + const separator = fullName ? ' - ' : ''; + details.push( + + {separator + m.nickname} + + ); + } + + let button = ''; + if (currentUserId !== m.id && ch.type !== 'D') { + button = ( + + ); + } + if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { popoverHtml.push(
- {m.username} + + +
+
+ {m.username} +
+
+ {details} +
+
+
+ {button} +
); count++; @@ -62,6 +189,7 @@ export default class PopoverListMembers extends React.Component { return ( - {popoverHtml} +
+ {popoverHtml} +
} > diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss index 484e63c7cc..4a2ad27485 100644 --- a/web/sass-files/sass/partials/_popover.scss +++ b/web/sass-files/sass/partials/_popover.scss @@ -61,3 +61,36 @@ @include opacity(1); } } + +#member-list-popover { + max-width: initial; + .popover-content > div { + max-height: 350px; + overflow-y: auto; + overflow-x: hidden; + > div { + border-bottom: 1px solid rgba(51,51,51,0.1); + padding: 8px 8px 8px 15px; + width: 100%; + box-sizing: content-box; + @include clearfix; + .profile-img { + border-radius: 50px; + margin-right: 8px; + } + .more-name { + font-weight: 600; + font-size: 0.95em; + overflow: hidden; + text-overflow: ellipsis; + } + .more-description { + @include opacity(0.7); + } + .profile-action { + margin-left: 8px; + margin-right: 18px; + } + } + } +} From 2008a20d9db400d942e0b2bd0bd4b8b432199731 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Mon, 26 Oct 2015 22:49:03 +0100 Subject: [PATCH 59/65] use 'Overlay' instead if 'OverlayTrigger' --- web/react/components/popover_list_members.jsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index b7d4fd9a9c..4f30adc434 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -3,7 +3,7 @@ var UserStore = require('../stores/user_store.jsx'); var Popover = ReactBootstrap.Popover; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; +var Overlay = ReactBootstrap.Overlay; const Utils = require('../utils/utils.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); @@ -22,6 +22,10 @@ export default class PopoverListMembers extends React.Component { this.closePopover = this.closePopover.bind(this); } + componentWillMount() { + this.setState({showPopover: false}); + } + componentDidMount() { const originalLeave = $.fn.popover.Constructor.prototype.leave; $.fn.popover.Constructor.prototype.leave = function onLeave(obj) { @@ -85,10 +89,7 @@ export default class PopoverListMembers extends React.Component { } closePopover() { - var overlay = this.refs.overlay; - if (overlay.state.isOverlayShown) { - overlay.setState({isOverlayShown: false}); - } + this.setState({showPopover: false}); } render() { @@ -188,12 +189,27 @@ export default class PopoverListMembers extends React.Component { } return ( - +
this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})} + > +
+ {countText} +
+
+ this.state.popoverTarget} + placement='bottom' + > - } - > -
-
- {countText} -
+
-
); } } From bced07a7f40c232a7a7f75ecd34ef0b7436f62f6 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Mon, 26 Oct 2015 23:29:55 +0100 Subject: [PATCH 60/65] add helper method to initiate a direct channel chat --- web/react/components/more_direct_channels.jsx | 62 +++++-------------- web/react/components/popover_list_members.jsx | 49 +++------------ web/react/utils/utils.jsx | 42 +++++++++++++ 3 files changed, 66 insertions(+), 87 deletions(-) diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 41746d1d7c..b0232fc084 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -1,13 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AsyncClient = require('../utils/async_client.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -const Constants = require('../utils/constants.jsx'); -const Client = require('../utils/client.jsx'); const Modal = ReactBootstrap.Modal; -const PreferenceStore = require('../stores/preference_store.jsx'); -const TeamStore = require('../stores/team_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); @@ -70,52 +64,24 @@ export default class MoreDirectChannels extends React.Component { } handleShowDirectChannel(teammate, e) { + e.preventDefault(); + if (this.state.loadingDMChannel !== -1) { return; } - e.preventDefault(); - - const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id); - let channel = ChannelStore.getByName(channelName); - - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); - AsyncClient.savePreferences([preference]); - - if (channel) { - Utils.switchChannel(channel); - - this.handleHide(); - } else { - this.setState({loadingDMChannel: teammate.id}); - - channel = { - name: channelName, - last_post_at: 0, - total_msg_count: 0, - type: 'D', - display_name: teammate.username, - teammate_id: teammate.id, - status: UserStore.getStatus(teammate.id) - }; - - Client.createDirectChannel( - channel, - teammate.id, - (data) => { - this.setState({loadingDMChannel: -1}); - - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); - - this.handleHide(); - }, - () => { - this.setState({loadingDMChannel: -1}); - window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; - } - ); - } + this.setState({loadingDMChannel: teammate.id}); + Utils.openDirectChannelToUser( + teammate, + (channel) => { + Utils.switchChannel(channel); + this.setState({loadingDMChannel: -1}); + this.handleHide(); + }, + () => { + this.setState({loadingDMChannel: -1}); + } + ); } handleUserChange() { diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 4f30adc434..9cffa24003 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -7,12 +7,6 @@ var Overlay = ReactBootstrap.Overlay; const Utils = require('../utils/utils.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); -const Client = require('../utils/client.jsx'); -const TeamStore = require('../stores/team_store.jsx'); - -const Constants = require('../utils/constants.jsx'); export default class PopoverListMembers extends React.Component { constructor(props) { @@ -51,41 +45,18 @@ export default class PopoverListMembers extends React.Component { handleShowDirectChannel(teammate, e) { e.preventDefault(); - const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id); - let channel = ChannelStore.getByName(channelName); - - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); - AsyncClient.savePreferences([preference]); - - if (channel) { - Utils.switchChannel(channel); - this.closePopover(); - } else { - channel = { - name: channelName, - last_post_at: 0, - total_msg_count: 0, - type: 'D', - display_name: teammate.username, - teammate_id: teammate.id, - status: UserStore.getStatus(teammate.id) - }; - - Client.createDirectChannel( - channel, - teammate.id, - (data) => { - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); - - this.closePopover(); - }, - () => { - window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; + Utils.openDirectChannelToUser( + teammate, + (channel, channelAlreadyExisted) => { + Utils.switchChannel(channel); + if (channelAlreadyExisted) { this.closePopover(); } - ); - } + }, + () => { + this.closePopover(); + } + ); } closePopover() { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 7a876d518c..fadab27a71 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -8,6 +8,7 @@ var PreferenceStore = require('../stores/preference_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; +var Client = require('./client.jsx'); var AsyncClient = require('./async_client.jsx'); var client = require('./client.jsx'); var Autolinker = require('autolinker'); @@ -1009,3 +1010,44 @@ export function windowWidth() { export function windowHeight() { return $(window).height(); } + +export function openDirectChannelToUser(user, successCb, errorCb) { + const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id); + let channel = ChannelStore.getByName(channelName); + + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + AsyncClient.savePreferences([preference]); + + if (channel) { + if ($.isFunction(successCb)) { + successCb(channel, true); + } + } else { + channel = { + name: channelName, + last_post_at: 0, + total_msg_count: 0, + type: 'D', + display_name: user.username, + teammate_id: user.id, + status: UserStore.getStatus(user.id) + }; + + Client.createDirectChannel( + channel, + user.id, + (data) => { + AsyncClient.getChannel(data.id); + if ($.isFunction(successCb)) { + successCb(data, false); + } + }, + () => { + window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; + if ($.isFunction(errorCb)) { + errorCb(); + } + } + ); + } +} From ae3e600c7d68a9fdf877f171143f054be980f6e6 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Mon, 26 Oct 2015 17:00:06 -0700 Subject: [PATCH 61/65] Fixed issue with missing help text for @all and @channel in mention list --- web/react/components/mention_list.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx index 8c1da942d8..60cae55d61 100644 --- a/web/react/components/mention_list.jsx +++ b/web/react/components/mention_list.jsx @@ -217,12 +217,17 @@ export default class MentionList extends React.Component { if (this.state.selectedMention === index) { isFocused = 'mentions-focus'; } + + if (!users[i].secondary_text) { + users[i].secondary_text = Utils.getFullName(users[i]); + } + mentions[index] = ( Date: Mon, 26 Oct 2015 17:22:36 -0700 Subject: [PATCH 62/65] Update Troubleshooting.md --- doc/install/Troubleshooting.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md index 46efc61fae..8ccc1f9415 100644 --- a/doc/install/Troubleshooting.md +++ b/doc/install/Troubleshooting.md @@ -15,3 +15,11 @@ - If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`. - After assigning the role the user needs to log out and log back in before the System Administrator role is applied. +#### Error Messages + +The following is a list of common error messages and solutions: + +##### "We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly" +- Message appears in blue bar on team site. Check that [your websocket port is properly configured](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-server). + + From bfa23b0075b0e2c5100cc396a4360255f47303d3 Mon Sep 17 00:00:00 2001 From: Girish S Date: Mon, 26 Oct 2015 17:09:15 +0530 Subject: [PATCH 63/65] adds trailing slash if missing from Directory in config.json does it only if "DriverName" == model.IMAGE_DRIVER_LOCAL, not sure if required for other drivers too --- utils/config.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/utils/config.go b/utils/config.go index fd9856a67c..6b34c76eda 100644 --- a/utils/config.go +++ b/utils/config.go @@ -159,6 +159,13 @@ func LoadConfig(fileName string) { configureLog(&config.LogSettings) TestConnection(&config) + if config.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { + dir := config.FileSettings.Directory + if len(dir) > 0 && dir[len(dir)-1:] != "/" { + config.FileSettings.Directory += "/" + } + } + Cfg = &config SanitizeOptions = getSanitizeOptions(Cfg) ClientCfg = getClientConfig(Cfg) From 9de0ceb22995d9bdf9b53d620471f1dd9d8042ae Mon Sep 17 00:00:00 2001 From: Girish S Date: Fri, 23 Oct 2015 10:47:26 +0530 Subject: [PATCH 64/65] auto-link mentions with user names having symbols this also handles the case where user_name having '_' --- web/react/utils/markdown.jsx | 9 ++++++--- web/react/utils/text_formatting.jsx | 10 +++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 01cc309b84..ad11a95ace 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -121,8 +121,11 @@ export class MattermostMarkdownRenderer extends marked.Renderer { paragraph(text) { let outText = text; + // required so markdown does not strip '_' from @user_names + outText = TextFormatting.doFormatMentions(text); + if (!('emoticons' in this.options) || this.options.emoticon) { - outText = TextFormatting.doFormatEmoticons(text); + outText = TextFormatting.doFormatEmoticons(outText); } if (this.formattingOptions.singleline) { @@ -136,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer { return `${header}${body}
`; } - text(text) { - return TextFormatting.doFormatText(text, this.formattingOptions); + text(txt) { + return TextFormatting.doFormatText(txt, this.formattingOptions); } } diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 4b6d872546..9f1a5a53fd 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -47,8 +47,8 @@ export function doFormatText(text, options) { const tokens = new Map(); // replace important words and phrases with tokens - output = autolinkUrls(output, tokens); output = autolinkAtMentions(output, tokens); + output = autolinkUrls(output, tokens); output = autolinkHashtags(output, tokens); if (!('emoticons' in options) || options.emoticon) { @@ -78,6 +78,13 @@ export function doFormatEmoticons(text) { return output; } +export function doFormatMentions(text) { + const tokens = new Map(); + let output = autolinkAtMentions(text, tokens); + output = replaceTokens(output, tokens); + return output; +} + export function sanitizeHtml(text) { let output = text; @@ -188,6 +195,7 @@ function autolinkAtMentions(text, tokens) { let output = text; output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); + return output; } From 50eb3d9fe46d6364b6f12201edfe0a401be3ccdd Mon Sep 17 00:00:00 2001 From: it33 Date: Tue, 27 Oct 2015 08:05:02 -0700 Subject: [PATCH 65/65] Added known issue for deleted channels --- doc/integrations/webhooks/Incoming-Webhooks.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md index 1216cb5dbf..b10b6e3423 100644 --- a/doc/integrations/webhooks/Incoming-Webhooks.md +++ b/doc/integrations/webhooks/Incoming-Webhooks.md @@ -90,8 +90,9 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl To see samples and community contributions, please visit . -#### Limitations +#### Known Issues - The `attachments` payload used in Slack is not yet supported - Overriding of usernames does not yet apply to notifications - Cannot supply `icon_emoji` to override the message icon +- Webhook UI fails when connected to deleted channel