Этот коммит содержится в:
Asaad Mahmood
2015-10-27 20:15:33 +05:00
родитель 68b02ffb9e 50eb3d9fe4
Коммит 179c4ea684
65 изменённых файлов: 1882 добавлений и 396 удалений

5
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)

Просмотреть файл

@@ -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).
## Installing Mattermost
@@ -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 theres some other way youd like to contribute, please contact us at info@mattermost.com. Wed love to meet you!

Просмотреть файл

@@ -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,10 +195,10 @@ 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 := \\_(ツ)_/¯"
message := `¯\\\_(ツ)_/¯`
parameters := strings.SplitN(command.Command, " ", 2)
if len(parameters) > 1 {
@@ -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

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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()))
}

Просмотреть файл

@@ -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,33 @@ 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))
// 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))
}
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) {

Просмотреть файл

@@ -18,7 +18,7 @@
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
<h2 style="font-weight: normal; margin-top: 10px;">You updated your password</h2>
<p>You updated your password for {{.Props.TeamDisplayName}} on {{ .Props.TeamURL }} by {{.Props.Method}}.<br> If this change wasn't initiated by you, please reply to this email and let us know.</p>
<p>You updated your password for {{.Props.TeamDisplayName}} on {{ .Props.TeamURL }} by {{.Props.Method}}.<br>If this change wasn't initiated by you, please contact your system administrator.</p>
</td>
</tr>
<tr>

Просмотреть файл

@@ -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)
}
}
@@ -854,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 {

Просмотреть файл

@@ -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

302
doc/install/Production-Debian.md Обычный файл
Просмотреть файл

@@ -0,0 +1,302 @@
# (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.
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```

Просмотреть файл

@@ -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

Просмотреть файл

@@ -12,7 +12,8 @@ To enable email, configure an SMTP email service as follows:
2. If you don't have an SMTP service, here are simple instructions to set one up with [Amazon Simple Email Service (SES)](https://aws.amazon.com/ses/):
2. Go to [Amazon SES console](https://console.aws.amazon.com/ses) then `SMTP Settings > Create My SMTP Credentials`
3. Copy the `Server Name`, `Port`, `SMTP Username`, and `SMTP Password` for Step 2 below.
4. From the `Domains` menu set up and verify a new domain, then enable `Generate DKIM Settings` for the domain.
4. From the `Domains` menu set up and verify a new domain, then enable `Generate DKIM Settings` for the domain.
1. We recommend you set up _[Sender Policy Framework](https://en.wikipedia.org/wiki/Sender_Policy_Framework) (SPF)_ and/or _[Domain Keys Identified Mail](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) (DKIM)_ for your email domain.
5. Choose an sender address like `mattermost@example.com` and click `Send a Test Email` to verify setup is working correctly.
2. **Configure SMTP settings**
@@ -57,7 +58,11 @@ To enable email, configure an SMTP email service as follows:
* Information needed
##### Hotmail
* Information needed
* Set **SMTP Username** to **your_email@hotmail.com**
* Set **SMTP Password** to **your_password**
* Set **SMTP Server** to **smtp-mail.outlook.com**
* Set **SMTP Port** to **587**
* Set **Connection Security** to **STARTTLS**
### Troubleshooting SMTP
@@ -91,4 +96,4 @@ Connected to mail.example.com.
250-STARTTLS
250-PIPELINING
250 8BITMIME
```
```

Просмотреть файл

@@ -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).

Просмотреть файл

@@ -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](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.
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

Просмотреть файл

@@ -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.

Просмотреть файл

@@ -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:_
@@ -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,43 +54,45 @@ 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. 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.
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": "<http://www.mattermost.com/>"}```, 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 <http://www.mattermost.com/|here> 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": "<http://www.mattermost.com/>"}```, 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 <http://www.mattermost.com/|here> 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 <http://mattermost.org/webhooks>.
#### 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

Просмотреть файл

@@ -0,0 +1,118 @@
# 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 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": "
---
##### 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) 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:
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
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 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
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": "<http://www.mattermost.com/>"}```, 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 <http://www.mattermost.com/|here> 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 <http://mattermost.org/webhooks>.
#### Limitations
- Overriding of usernames does not yet apply to notifications
- Cannot supply `icon_emoji` to override the message icon

Просмотреть файл

@@ -9,7 +9,8 @@ import (
)
const (
RESP_EXECUTED = "executed"
RESP_EXECUTED = "executed"
RESP_NOT_IMPLEMENTED = "not implemented"
)
type Command struct {

Просмотреть файл

@@ -23,6 +23,13 @@ type IncomingWebhook struct {
TeamId string `json:"team_id"`
}
type IncomingWebhookRequest struct {
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelName string `json:"channel"`
}
func (o *IncomingWebhook) ToJson() string {
b, err := json.Marshal(o)
if err != nil {
@@ -104,3 +111,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
}
}

Просмотреть файл

@@ -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"

Просмотреть файл

@@ -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")
}
}

Просмотреть файл

@@ -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"}
@@ -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,66 @@ 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, " ")
inChannels := []string{}
fromUsers := []string{}
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)
}
}
fromUser := flags["from"]
paramsList := []*SearchParams{}
var plainParams *SearchParams
if len(plainTerms) > 0 {
plainParams = &SearchParams{
Terms: strings.Join(plainTerms, " "),
IsHashtag: false,
InChannel: inChannel,
FromUser: fromUser,
}
paramsList = append(paramsList, &SearchParams{
Terms: plainTerms,
IsHashtag: false,
InChannels: inChannels,
FromUsers: fromUsers,
})
}
var hashtagParams *SearchParams
if len(hashtagTerms) > 0 {
hashtagParams = &SearchParams{
Terms: strings.Join(hashtagTerms, " "),
IsHashtag: true,
InChannel: inChannel,
FromUser: fromUser,
}
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 plainParams == nil && hashtagParams == nil && (inChannel != "" || fromUser != "") {
plainParams = &SearchParams{
Terms: "",
IsHashtag: false,
InChannel: inChannel,
FromUser: fromUser,
}
if len(plainTerms) == 0 && len(hashtagTerms) == 0 {
paramsList = append(paramsList, &SearchParams{
Terms: "",
IsHashtag: true,
InChannels: inChannels,
FromUsers: fromUsers,
})
}
return plainParams, hashtagParams
return paramsList
}

Просмотреть файл

@@ -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)
}
}

Просмотреть файл

@@ -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() {

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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")
}

Просмотреть файл

@@ -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)", "{}")
}

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -136,16 +136,15 @@ export default class ChannelNotifications extends React.Component {
var inputs = [];
inputs.push(
<div>
<div key='channel-notification-level-radio'>
<div className='radio'>
<label>
<input
type='radio'
checked={notifyActive[0]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'default')}
>
/>
{`Global default (${globalNotifyLevelName})`}
</input>
</label>
<br/>
</div>
@@ -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'}
</input>
</label>
<br/>
</div>
@@ -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'}
</input>
</label>
<br/>
</div>
@@ -179,9 +176,8 @@ export default class ChannelNotifications extends React.Component {
type='radio'
checked={notifyActive[3]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'none')}
>
/>
{'Never'}
</input>
</label>
</div>
</div>
@@ -274,16 +270,15 @@ export default class ChannelNotifications extends React.Component {
if (this.state.activeSection === 'markUnreadLevel') {
const inputs = [(
<div>
<div key='channel-notification-unread-radio'>
<div className='radio'>
<label>
<input
type='radio'
checked={this.state.markUnreadLevel === 'all'}
onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'all')}
>
/>
{'For all unread messages'}
</input>
</label>
<br />
</div>
@@ -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'}
</input>
</label>
<br />
</div>
@@ -370,7 +364,7 @@ export default class ChannelNotifications extends React.Component {
data-dismiss='modal'
>
<span aria-hidden='true'>&times;</span>
<span className='sr-only'>Close</span>
<span className='sr-only'>{'Close'}</span>
</button>
<h4 className='modal-title'>Notification Preferences for <span className='name'>{this.state.title}</span></h4>
</div>

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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();
@@ -195,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;
}
@@ -240,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);
@@ -252,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) {

Просмотреть файл

@@ -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});
}

Просмотреть файл

@@ -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] = (
<Mention
key={'mention_key_' + index}
ref={'mention' + index}
username={users[i].username}
secondary_text={Utils.getFullName(users[i])}
secondary_text={users[i].secondary_text}
id={users[i].id}
listId={index}
isFocused={isFocused}

Просмотреть файл

@@ -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() {
@@ -169,7 +135,7 @@ export default class MoreDirectChannels extends React.Component {
}
return (
<tr>
<tr key={'direct-channel-row-user' + user.id}>
<td
key={user.id}
className='direct-channel'

Просмотреть файл

@@ -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.updateTypingText = this.updateTypingText.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.updateTypingText();
}
}
@@ -36,30 +36,53 @@ 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.updateTypingText();
}.bind(this, username), Constants.UPDATE_TYPING_MS);
this.updateTypingText();
} 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.updateTypingText();
}
}
updateTypingText() {
const users = Object.keys(this.typingUsers);
let text = '';
switch (users.length) {
case 0:
text = '';
break;
case 1:
text = users[0] + ' is typing...';
break;
default:
const last = users.pop();
text = users.join(', ') + ' and ' + last + ' are typing...';
break;
}
this.setState({text});
}
render() {
return (
<span className='msg-typing'>{this.state.text}</span>

Просмотреть файл

@@ -3,9 +3,23 @@
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');
export default class PopoverListMembers extends React.Component {
constructor(props) {
super(props);
this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
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) {
@@ -27,12 +41,36 @@ export default class PopoverListMembers extends React.Component {
}
};
}
handleShowDirectChannel(teammate, e) {
e.preventDefault();
Utils.openDirectChannelToUser(
teammate,
(channel, channelAlreadyExisted) => {
Utils.switchChannel(channel);
if (channelAlreadyExisted) {
this.closePopover();
}
},
() => {
this.closePopover();
}
);
}
closePopover() {
this.setState({showPopover: 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 +78,74 @@ export default class PopoverListMembers extends React.Component {
});
members.forEach((m, i) => {
const details = [];
const fullName = Utils.getFullName(m);
if (fullName) {
details.push(
<span
key={`${m.id}__full-name`}
className='full-name'
>
{fullName}
</span>
);
}
if (m.nickname) {
const separator = fullName ? ' - ' : '';
details.push(
<span
key={`${m.nickname}__nickname`}
>
{separator + m.nickname}
</span>
);
}
let button = '';
if (currentUserId !== m.id && ch.type !== 'D') {
button = (
<button
type='button'
className='btn btn-primary btn-message'
onClick={(e) => this.handleShowDirectChannel(m, e)}
>
{'Message'}
</button>
);
}
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push(
<div
className='text--nowrap'
key={'popover-member-' + i}
>
{m.username}
<img
className='profile-img pull-left'
width='38'
height='38'
src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`}
/>
<div className='pull-left'>
<div
className='more-name'
>
{m.username}
</div>
<div
className='more-description'
>
{details}
</div>
</div>
<div
className='pull-right profile-action'
>
{button}
</div>
</div>
);
count++;
@@ -61,29 +160,37 @@ export default class PopoverListMembers extends React.Component {
}
return (
<OverlayTrigger
trigger='click'
placement='bottom'
rootClose={true}
overlay={
<div>
<div
id='member_popover'
ref='member_popover_target'
onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})}
>
<div>
{countText}
<span
className='fa fa-user'
aria-hidden='true'
/>
</div>
</div>
<Overlay
rootClose={true}
onHide={this.closePopover}
show={this.state.showPopover}
target={() => this.state.popoverTarget}
placement='bottom'
>
<Popover
title='Members'
id='member-list-popover'
>
{popoverHtml}
<div>
{popoverHtml}
</div>
</Popover>
}
>
<div id='member_popover'>
<div>
{countText}
<span
className='fa fa-user'
aria-hidden='true'
/>
</div>
</Overlay>
</div>
</OverlayTrigger>
);
}
}

249
web/react/components/search_autocomplete.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,249 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// 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 = new Map([
['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.handleKeyDown = this.handleKeyDown.bind(this);
this.completeWord = this.completeWord.bind(this);
this.updateSuggestions = this.updateSuggestions.bind(this);
this.state = {
show: false,
mode: '',
filter: '',
selection: 0,
suggestions: new Map()
};
}
componentDidMount() {
$(document).on('click', this.handleDocumentClick);
}
componentWillUnmount() {
$(document).off('click', this.handleDocumentClick);
}
handleClick(value) {
this.completeWord(value);
}
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 [modeForPattern, pattern] of patterns) {
const result = pattern.exec(preText);
if (result) {
mode = modeForPattern;
filter = result[1];
break;
}
}
if (mode !== this.state.mode || filter !== this.state.filter) {
this.updateSuggestions(mode, filter);
}
this.setState({
mode,
filter,
show: mode || filter
});
}
handleKeyDown(e) {
if (!this.state.show || this.state.suggestions.length === 0) {
return;
}
if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) {
e.preventDefault();
let selection = this.state.selection;
if (e.which === KeyCodes.UP) {
selection -= 1;
} else {
selection += 1;
}
if (selection >= 0 && selection < this.state.suggestions.length) {
this.setState({
selection
});
}
} else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) {
e.preventDefault();
this.completeSelectedWord();
}
}
completeSelectedWord() {
if (this.state.mode === 'channels') {
this.completeWord(this.state.suggestions[this.state.selection].name);
} else if (this.state.mode === 'users') {
this.completeWord(this.state.suggestions[this.state.selection].username);
}
}
completeWord(value) {
// add a space so that anything else typed doesn't interfere with the search flag
this.props.completeWord(this.state.filter, value + ' ');
this.setState({
show: false,
mode: '',
filter: '',
selection: 0
});
}
updateSuggestions(mode, filter) {
let suggestions = [];
if (mode === 'channels') {
let channels = ChannelStore.getAll();
if (filter) {
channels = channels.filter((channel) => channel.name.startsWith(filter));
}
channels.sort((a, b) => a.name.localeCompare(b.name));
suggestions = channels;
} else if (mode === 'users') {
let users = UserStore.getActiveOnlyProfileList();
if (filter) {
users = users.filter((user) => user.username.startsWith(filter));
}
users.sort((a, b) => a.username.localeCompare(b.username));
suggestions = users;
}
let selection = this.state.selection;
// keep the same user/channel selected if it's still visible as a suggestion
if (selection > 0 && this.state.suggestions.length > 0) {
// we can't just use indexOf to find if the selection is still in the list since they are different javascript objects
const currentSelectionId = this.state.suggestions[selection].id;
let found = false;
for (let i = 0; i < suggestions.length; i++) {
if (suggestions[i].id === currentSelectionId) {
selection = i;
found = true;
break;
}
}
if (!found) {
selection = 0;
}
} else {
selection = 0;
}
this.setState({
suggestions,
selection
});
}
render() {
if (!this.state.show || this.state.suggestions.length === 0) {
return null;
}
let suggestions = [];
if (this.state.mode === 'channels') {
suggestions = this.state.suggestions.map((channel, index) => {
let className = 'search-autocomplete__channel';
if (this.state.selection === index) {
className += ' selected';
}
return (
<div
key={channel.name}
ref={channel.name}
onClick={this.handleClick.bind(this, channel.name)}
className={className}
>
{channel.name}
</div>
);
});
} else if (this.state.mode === 'users') {
suggestions = this.state.suggestions.map((user, index) => {
let className = 'search-autocomplete__user';
if (this.state.selection === index) {
className += ' selected';
}
return (
<div
key={user.username}
ref={user.username}
onClick={this.handleClick.bind(this, user.username)}
className={className}
>
<img
className='profile-img'
src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
/>
{user.username}
</div>
);
});
}
return (
<div
ref='container'
className='search-autocomplete'
>
{suggestions}
</div>
);
}
}
SearchAutocomplete.propTypes = {
completeWord: React.PropTypes.func.isRequired
};

Просмотреть файл

@@ -9,6 +9,7 @@ var utils = require('../utils/utils.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
var Popover = ReactBootstrap.Popover;
var SearchAutocomplete = require('./search_autocomplete.jsx');
export default class SearchBar extends React.Component {
constructor() {
@@ -16,11 +17,13 @@ export default class SearchBar extends React.Component {
this.mounted = false;
this.onListenerChange = this.onListenerChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleUserFocus = this.handleUserFocus.bind(this);
this.handleUserBlur = this.handleUserBlur.bind(this);
this.performSearch = this.performSearch.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.completeWord = this.completeWord.bind(this);
const state = this.getSearchTermStateFromStores();
state.focused = false;
@@ -74,11 +77,18 @@ export default class SearchBar extends React.Component {
results: null
});
}
handleKeyDown(e) {
if (this.refs.autocomplete) {
this.refs.autocomplete.handleKeyDown(e);
}
}
handleUserInput(e) {
var term = e.target.value;
PostStore.storeSearchTerm(term);
PostStore.emitSearchTermChange(false);
this.setState({searchTerm: term});
this.refs.autocomplete.handleInputChange(e.target, term);
}
handleMouseInput(e) {
e.preventDefault();
@@ -95,9 +105,16 @@ export default class SearchBar extends React.Component {
performSearch(terms, isMentionSearch) {
if (terms.length) {
this.setState({isSearching: true});
// append * if not present
let searchTerms = terms;
if (searchTerms.search(/\*\s*$/) === -1) {
searchTerms = searchTerms + '*';
}
client.search(
terms,
function success(data) {
searchTerms,
(data) => {
this.setState({isSearching: false});
if (utils.isMobile()) {
ReactDOM.findDOMNode(this.refs.search).value = '';
@@ -108,11 +125,11 @@ export default class SearchBar extends React.Component {
results: data,
is_mention_search: isMentionSearch
});
}.bind(this),
function error(err) {
},
(err) => {
this.setState({isSearching: false});
AsyncClient.dispatchError(err, 'search');
}.bind(this)
}
);
}
}
@@ -120,6 +137,24 @@ export default class SearchBar extends React.Component {
e.preventDefault();
this.performSearch(this.state.searchTerm.trim());
}
completeWord(partialWord, word) {
const textbox = ReactDOM.findDOMNode(this.refs.search);
let text = textbox.value;
const caret = utils.getCaretPosition(textbox);
const preText = text.substring(0, caret - partialWord.length);
const postText = text.substring(caret);
text = preText + word + postText;
textbox.value = text;
utils.setCaretPosition(textbox, preText.length + word.length);
PostStore.storeSearchTerm(text);
PostStore.emitSearchTermChange(false);
this.setState({searchTerm: text});
}
render() {
var isSearching = null;
if (this.state.isSearching) {
@@ -143,12 +178,13 @@ export default class SearchBar extends React.Component {
className='search__clear'
onClick={this.clearFocus}
>
Cancel
{'Cancel'}
</span>
<form
role='form'
className='search__form relative-div'
onSubmit={this.handleSubmit}
style={{overflow: 'visible'}}
>
<span className='glyphicon glyphicon-search sidebar__search-icon' />
<input
@@ -160,10 +196,16 @@ export default class SearchBar extends React.Component {
onFocus={this.handleUserFocus}
onBlur={this.handleUserBlur}
onChange={this.handleUserInput}
onKeyDown={this.handleKeyDown}
onMouseUp={this.handleMouseInput}
/>
{isSearching}
<SearchAutocomplete
ref='autocomplete'
completeWord={this.completeWord}
/>
<Popover
id='searchbar-help-popup'
placement='bottom'
className={helpClass}
>

Просмотреть файл

@@ -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.setUnreadCountPerChannel = this.setUnreadCountPerChannel.bind(this);
this.getUnreadCount = this.getUnreadCount.bind(this);
this.isLeaving = new Map();
@@ -48,8 +51,45 @@ export default class Sidebar extends React.Component {
state.showDirectChannelsModal = false;
state.loadingDMChannel = -1;
state.windowWidth = Utils.windowWidth();
this.state = state;
this.unreadCountPerChannel = {};
this.setUnreadCountPerChannel();
}
setUnreadCountPerChannel() {
const channels = ChannelStore.getAll();
const members = ChannelStore.getAllMembers();
const channelUnreadCounts = {};
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;
}
channelUnreadCounts[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount};
});
this.unreadCountPerChannel = channelUnreadCounts;
}
getUnreadCount(channelId) {
let mentions = 0;
let msgs = 0;
if (channelId) {
return this.unreadCountPerChannel[channelId] ? this.unreadCountPerChannel[channelId] : {msgs, mentions};
}
Object.keys(this.unreadCountPerChannel).forEach((chId) => {
msgs += this.unreadCountPerChannel[chId].msgs;
mentions += this.unreadCountPerChannel[chId].mentions;
});
return {msgs, mentions};
}
getStateFromStores() {
const members = ChannelStore.getAllMembers();
@@ -192,7 +232,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 +316,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 +328,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 +345,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 = <span className='badge pull-right small'>{msgCount}</span>;
this.badgesActive = true;
}
} else if (channelMember.mention_count > 0) {
// public and private channels only show badges for mentions
badge = <span className='badge pull-right small'>{channelMember.mention_count}</span>;
if (unreadCount.mentions) {
badge = <span className='badge pull-right small'>{unreadCount.mentions}</span>;
this.badgesActive = true;
}
} else if (this.state.loadingDMChannel === index && channel.type === 'D') {
@@ -434,6 +470,8 @@ export default class Sidebar extends React.Component {
render() {
this.badgesActive = false;
this.setUnreadCountPerChannel();
// 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;

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -104,21 +104,19 @@ export default class TeamSignupWelcomePage extends React.Component {
return (
<div>
<p>
<img
className='signup-team-logo'
src='/static/images/logo.png'
/>
<h3 className='sub-heading'>Welcome to:</h3>
<h1 className='margin--top-none'>{global.window.mm_config.SiteName}</h1>
</p>
<img
className='signup-team-logo'
src='/static/images/logo.png'
/>
<h3 className='sub-heading'>Welcome to:</h3>
<h1 className='margin--top-none'>{global.window.mm_config.SiteName}</h1>
<p className='margin--less'>Let's set up your new team</p>
<p>
<div>
Please confirm your email address:<br />
<div className='inner__content'>
<div className='block--gray'>{this.props.state.team.email}</div>
</div>
</p>
</div>
<p className='margin--extra color--light'>
Your account will administer the new team site. <br />
You can add other administrators later.

Просмотреть файл

@@ -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(
<div
className='col-xs-6 col-sm-3 premade-themes'
key={'premade-theme-key' + k}
>
<div
className={activeClass}
onClick={() => this.props.updateTheme(k)}
>
<label>
<img
className='img-responsive'
src={'/static/images/themes/code_themes/' + k + '.png'}
/>
<div className='theme-label'>{Constants.CODE_THEMES[k]}</div>
</label>
</div>
</div>
);
}
}
return (
<div className='row'>
{premadeThemes}
</div>
);
}
}
CodeThemeChooser.propTypes = {
theme: React.PropTypes.object.isRequired,
updateTheme: React.PropTypes.func.isRequired
};

Просмотреть файл

@@ -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 {
</div>
{custom}
<hr />
{serverError}
<strong className='radio'>{'Code Theme'}</strong>
<CodeThemeChooser
theme={this.state.theme}
updateTheme={this.updateCodeTheme}
/>
<hr />
{serverError}
<a
className='btn btn-sm btn-primary'
href='#'

Просмотреть файл

@@ -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)
);

Просмотреть файл

@@ -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",

Просмотреть файл

@@ -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();
};
@@ -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);
}

Просмотреть файл

@@ -48,6 +48,7 @@ class UserStoreClass extends EventEmitter {
this.getProfilesUsernameMap = this.getProfilesUsernameMap.bind(this);
this.getProfiles = this.getProfiles.bind(this);
this.getActiveOnlyProfiles = this.getActiveOnlyProfiles.bind(this);
this.getActiveOnlyProfileList = this.getActiveOnlyProfileList.bind(this);
this.saveProfile = this.saveProfile.bind(this);
this.setSessions = this.setSessions.bind(this);
this.getSessions = this.getSessions.bind(this);
@@ -215,6 +216,19 @@ class UserStoreClass extends EventEmitter {
return active;
}
getActiveOnlyProfileList() {
const profileMap = this.getActiveOnlyProfiles();
const profiles = [];
for (const id in profileMap) {
if (profileMap.hasOwnProperty(id)) {
profiles.push(profileMap[id]);
}
}
return profiles;
}
saveProfile(profile) {
var ps = this.getProfiles();
ps[profile.id] = profile;

Просмотреть файл

@@ -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');
}

Просмотреть файл

@@ -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};

Просмотреть файл

@@ -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',
@@ -132,6 +133,7 @@ module.exports = {
OFFLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path fill='#cccccc' d='M6.002,7.143C5.645,7.363,5.167,7.52,4.502,7.52c-2.493,0-2.5-2.02-2.5-2.02S1.029,5.607,0.775,6.004C0.41,6.577,0.15,7.716,0.049,8.545c-0.025,0.145-0.057,0.537-0.05,0.598c0.162,1.295,2.237,2.321,4.375,2.357c0.043,0.001,0.085,0.001,0.127,0.001c0.043,0,0.084,0,0.127-0.001c1.879-0.023,3.793-0.879,4.263-2h-2.89L6.002,7.143L6.002,7.143z M4.501,5.488c1.372,0,2.483-1.117,2.483-2.494c0-1.378-1.111-2.495-2.483-2.495c-1.371,0-2.481,1.117-2.481,2.495C2.02,4.371,3.13,5.488,4.501,5.488z M7.002,6.5v2h5v-2H7.002z'/></g></g></svg>",
MENU_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='4px' height='16px' viewBox='0 0 8 32' enable-background='new 0 0 8 32' xml:space='preserve'> <g> <circle cx='4' cy='4.062' r='4'/> <circle cx='4' cy='16' r='4'/> <circle cx='4' cy='28' r='4'/> </g> </svg>",
COMMENT_ICON: "<svg version='1.1' id='Layer_2' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='15px' height='15px' viewBox='1 1.5 15 15' enable-background='new 1 1.5 15 15' xml:space='preserve'> <g> <g> <path fill='#211B1B' d='M14,1.5H3c-1.104,0-2,0.896-2,2v8c0,1.104,0.896,2,2,2h1.628l1.884,3l1.866-3H14c1.104,0,2-0.896,2-2v-8 C16,2.396,15.104,1.5,14,1.5z M15,11.5c0,0.553-0.447,1-1,1H8l-1.493,2l-1.504-1.991L5,12.5H3c-0.552,0-1-0.447-1-1v-8 c0-0.552,0.448-1,1-1h11c0.553,0,1,0.448,1,1V11.5z'/> </g> </g> </svg>",
UPDATE_TYPING_MS: 5000,
THEMES: {
default: {
type: 'Mattermost',
@@ -300,6 +302,13 @@ module.exports = {
uiName: 'Mention Highlight Link'
}
],
CODE_THEMES: {
github: 'GitHub',
solarized_light: 'Solarized light',
monokai: 'Monokai',
solarized_dark: 'Solarized Dark'
},
DEFAULT_CODE_THEME: 'github',
Preferences: {
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
CATEGORY_DISPLAY_SETTINGS: 'display_settings'
@@ -311,6 +320,32 @@ module.exports = {
RIGHT: 39,
BACKSPACE: 8,
ENTER: 13,
ESCAPE: 27
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',
java: 'Java',
ini: 'ini'
}
};

Просмотреть файл

@@ -6,6 +6,34 @@ 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 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;
export class MattermostMarkdownRenderer extends marked.Renderer {
constructor(options, formattingOptions = {}) {
super(options);
@@ -15,6 +43,43 @@ 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);
highlightJs.registerLanguage('java', highlightJsJava);
highlightJs.registerLanguage('ini', highlightJsIni);
}
code(code, language) {
if (!language || highlightJs.listLanguages().indexOf(language) < 0) {
let parsed = super.code(code, language);
return '<code class="hljs">' + $(parsed).text() + '</code>';
}
let parsed = highlightJs.highlight(language, code);
return '<div class="post-body--code">' +
'<span class="post-body--code__language">' + HighlightedLanguages[language] + '</span>' +
'<code style="white-space: pre;" class="hljs">' + parsed.value + '</code>' +
'</div>';
}
br() {
@@ -56,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) {
@@ -71,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
}
text(text) {
return TextFormatting.doFormatText(text, this.formattingOptions);
text(txt) {
return TextFormatting.doFormatText(txt, this.formattingOptions);
}
}

Просмотреть файл

@@ -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;
@@ -91,6 +98,7 @@ export function sanitizeHtml(text) {
return output;
}
// Convert URLs into tokens
function autolinkUrls(text, tokens) {
function replaceUrlWithToken(autolinker, match) {
const linkText = match.getMatchedText();
@@ -132,26 +140,61 @@ 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: `<a class='mention-link' href='#' data-mention='${username}'>${mention}</a>`,
originalText: mention,
extraText
});
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: `<a class='mention-link' href='#' data-mention='${usernameLower}'>${mention}</a>`,
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 - 1);
if (mentionExists(usernameLower)) {
const extraText = originalUsername.substr(c - 1);
const alias = addToken(usernameLower, '@' + usernameLower, 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;
}
@@ -169,10 +212,9 @@ function highlightCurrentMentions(text, tokens) {
const newAlias = `MM_SELFMENTION${index}`;
newTokens.set(newAlias, {
value: `<span class='mention-highlight'>${alias}</span>`,
value: `<span class='mention-highlight'>${alias}</span>` + token.extraText,
originalText: token.originalText
});
output = output.replace(alias, newAlias);
}
}
@@ -246,7 +288,7 @@ function highlightSearchTerm(text, tokens, searchTerm) {
var newTokens = new Map();
for (const [alias, token] of tokens) {
if (token.originalText === searchTerm) {
if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) {
const index = tokens.size + newTokens.size;
const newAlias = `MM_SEARCHTERM${index}`;
@@ -276,7 +318,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) {

Просмотреть файл

@@ -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');
@@ -20,6 +21,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;
@@ -402,6 +404,11 @@ export function toTitleCase(str) {
}
export function applyTheme(theme) {
if (!theme.codeTheme) {
theme.codeTheme = Constants.DEFAULT_CODE_THEME;
}
updateCodeTheme(theme.codeTheme);
if (theme.sidebarBg) {
changeCss('.sidebar--left, .settings-modal .settings-table .settings-links, .sidebar--menu', 'background:' + theme.sidebarBg, 1);
}
@@ -588,6 +595,27 @@ export function rgb2hex(rgbIn) {
return '#' + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]);
}
export function updateCodeTheme(theme) {
const path = '/static/css/highlight/' + theme + '.css';
const $link = $('link.code_theme');
if (path !== $link.attr('href')) {
changeCss('code.hljs', 'visibility: hidden');
var xmlHTTP = new XMLHttpRequest();
xmlHTTP.open('GET', path, true);
xmlHTTP.onload = function onLoad() {
$link.attr('href', path);
if (isBrowserFirefox()) {
$link.one('load', () => {
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') {
@@ -982,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();
}
}
);
}
}

Просмотреть файл

@@ -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;
}
}
}
}

Просмотреть файл

@@ -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%;

Просмотреть файл

@@ -109,3 +109,43 @@
.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 {
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;
.profile-img {
height: 32px;
margin-right: 6px;
width: 32px;
@include border-radius(16px);
}
&.selected {
background-color:rgba(51, 51, 51, 0.15);
}
}

1
web/static/css/highlight Символическая ссылка
Просмотреть файл

@@ -0,0 +1 @@
../../react/node_modules/highlight.js/styles/

Двоичные данные
web/static/images/themes/code_themes/github.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 9.4 KiB

Двоичные данные
web/static/images/themes/code_themes/monokai.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 9.1 KiB

Двоичные данные
web/static/images/themes/code_themes/solarized_dark.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 8.0 KiB

Двоичные данные
web/static/images/themes/code_themes/solarized_light.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 8.7 KiB

Просмотреть файл

@@ -24,6 +24,7 @@
<link rel="stylesheet" href="/static/css/bootstrap-colorpicker.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
<link rel="stylesheet" href="/static/css/google-fonts.css">
<link rel="stylesheet" class="code_theme" href="">
<link id="favicon" rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon">

Просмотреть файл

@@ -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)