diff options
64 files changed, 1459 insertions, 1179 deletions
diff --git a/.gitignore b/.gitignore index 4c343021e..422c6d5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,14 +9,15 @@ npm-debug.log web/static/js/bundle*.js web/static/js/bundle*.js.map web/static/js/libs*.js -.npminstall config/active.dat +# Enteprise imports file +imports.go + # Build Targets -.prepare -.prepare-go -.prepare-jsx +.prebuild +.npminstall # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o @@ -69,6 +70,3 @@ api/data/* .agignore .ctags tags - -model/version.go -model/version.go.bak diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 71dcbefbc..000000000 --- a/.travis.yml +++ /dev/null @@ -1,56 +0,0 @@ -language: generic -sudo: required -services: -- docker -env: -- TRAVIS_DB=mysql -- TRAVIS_DB=postgres -before_install: - - docker run --name mattermost-mysql -e MYSQL_ROOT_PASSWORD=mostest -e MYSQL_USER=mmuser -e MYSQL_PASSWORD=mostest -e MYSQL_DATABASE=mattermost_test -d mysql:5.7 - - docker run --name mattermost-postgres -e POSTGRES_USER=mmuser -e POSTGRES_PASSWORD=mostest -d postgres:9.4 - - sleep 10 - - docker exec mattermost-postgres psql -c 'create database mattermost_test ;' -U postgres - - docker exec mattermost-postgres psql -c 'grant all privileges on database "mattermost_test" to mmuser ;' -U postgres -script: make dist-travis -addons: - hosts: - - 127.0.0.1 dockerhost -after_success: - - sudo chown -R `whoami` dist - - sudo chmod -R 777 dist - - cd dist && curl -F "pr_number=$TRAVIS_PULL_REQUEST" -F "file=@mattermost.tar.gz" mattermod.mattermost.com:8087/upload_pr_build - - md5sum mattermost.tar.gz > mm.md5 - - sha1sum mattermost.tar.gz > mm.sha1 - - sha256sum mattermost.tar.gz > mm.sha256 - - cat mm.md5 mm.sha1 mm.sha256 - - cd .. -before_deploy: - - sudo rm -rf dist/mattermost - - rvm 1.9.3 do gem install mime-types -v 2.6.2 -deploy: -# Github releases, builds only on tags - - provider: releases - api_key: - secure: ma8Y0oimU+LB6LTAh8to2E1/ghaDPhcsAFXBrODsHpd4JgxA6HYoEwSEBCJFHSpu/JteclsxSTfp9hcuzw/IOtlwlSAiVoBZ60s24MRKTIAQNtrJ4QrX5wyfAZi+Bcuk/E8NynmoIW5qpaElSAdjgocyjAJIQ5ChMEztglL0cAEBXQRWbWMqSZ0hVLPrKDCIkWIyv3pFxqdLOxktkzxW07r2dlT0hppXR3dCaPJo0nelArS2H3LdN/3Iv6cAddfS27RaZkqDj/PDh6OZr4EguC99TxlVNChIr7nPr3/OiAssbkvEnhlSLeABFO9+7KfutL2WhAjpFXTjtPVq6Qalc8UW0K0gxq//sVfhb1MzjenmdOf06uB2bilQ8kgwHo7dDdRZBqqAtxQ6Q0Ht3SFMj6v/1zVD3s+YX/kWCEbUTHm6r2G/eF794ozcJyU+6j1L8hm6mvf8Mr9XCqBfgpZy6FCLX+9OKdMvX2jY8reo3Xz1PA9R6yzhN08vjku+jW+fsoYrBLd0fY1UGK2uOuvBByCeJzXupd3YpBMjEyRupVxqEj7K0GWOJeml65mkqKSNsHdDSeSjMpb8mwneZyTbdjsxCFQRLcLgpAajFrkk4G2Yz3KfhXSo29XKEGX+EbY5NuP8KmDsBsguPI0zfwv/co0hAY8PIIcehxcdoR9Vb2c= - file: dist/mattermost.tar.gz - skip_cleanup: true - on: - repo: mattermost/platform - tags: true - condition: $TRAVIS_DB = mysql - -# S3 deploy for latest master - - provider: s3 - access_key_id: AKIAJCO3KJYEGWJIKDIQ - secret_access_key: - secure: p66X2tJBmKgtcVyPtGgkAwW29IiRojqGA39RjCJkIWNTJ0e/9JvBOiMS2c4a7I4aOads38rsthwdaigBWagDWNH7bGsEZN7B0TszZuFAuU+XGjU5A66MIOfFfzbUg8AnByysr+XG5/bknFIrP/XhM2fbRr6gbYrFUK7TNkpgjFs5u3BzUrz2iTAV8uOpSJqKSnaf0pTZk1EywOK/X8W8ViIjc7Di3FzQcqIW9K3D27N+3rVsv8SRT1hWASVlnG6aThqqebiM8FCGCzAYVgQb3h3Wu8JT5fIz7Qo7A6siVRwNBwWwzP8HkGoinEK32Wsj/fDXk27vjpFQO/+9sV0xfcTbIZA6MnuYWF4rHOT59KcshCWCD3V0FopX57p/dtOzM9+6lxIctAT++izxWoZit/5c5A4633iY1d+RMeTko1POix6MSlxPMRHZUFwSXROgFuWWRpyD6TlUTCST9/wTTd0WDPklAAiYcnuEPW3qCnw0r0xkrA4AwWUXqXdAIwDt5bA27KcjRyY4Fofv9NxH09BNuBTXNPrvnYPZMmaKrv+HOX3NFTreuV6+5LJdhYUxYSBvSWo1jeWIQ5Q9RUdTU0PqmKpMhJKbKey/S4gxCXHg2HR8DwLCcbIZcvneF9yPEAT71YA6zpLKoPVSwWwH97huKSzjpic/RUfFXQOcgCQ= - bucket: mattermost-travis-master - local_dir: dist - acl: public_read - region: us-east-1 - skip_cleanup: true - detect_encoding: true - on: - repo: mattermost/platform - branch: master - condition: $TRAVIS_DB = mysql @@ -1,47 +1,47 @@ -.PHONY: all dist dist-local dist-travis start-docker build-server package build-client test travis-init build-container stop-docker clean-docker clean nuke run run-client run-server stop stop-client stop-server setup-mac cleandb docker-build docker-run restart-server +.PHONY: build package run stop run-client run-server stop-client stop-server restart-server restart-client start-docker clean-dist clean nuke check-style check-unit-tests test dist setup-mac prepare-enteprise -GOPATH ?= $(GOPATH:) -GOFLAGS ?= $(GOFLAGS:) +# Build Flags BUILD_NUMBER ?= $(BUILD_NUMBER:) BUILD_DATE = $(shell date -u) BUILD_HASH = $(shell git rev-parse HEAD) - -ENTERPRISE_DIR ?= ../enterprise -BUILD_ENTERPRISE ?= true - -GO=$(GOPATH)/bin/godep go -ESLINT=node_modules/eslint/bin/eslint.js - +# If we don't set the build number it defaults to dev ifeq ($(BUILD_NUMBER),) BUILD_NUMBER := dev endif - -ifeq ($(TRAVIS_BUILD_NUMBER),) - BUILD_NUMBER := dev +BUILD_ENTERPRISE_DIR ?= ../enterprise +BUILD_ENTERPRISE ?= true +BUILD_ENTERPRISE_READY = false +ifneq ($(wildcard $(BUILD_ENTERPRISE_DIR)/.),) + ifeq ($(BUILD_ENTERPRISE),true) + BUILD_ENTERPRISE_READY = true + else + BUILD_ENTERPRISE_READY = false + endif else - BUILD_NUMBER := $(TRAVIS_BUILD_NUMBER) + BUILD_ENTERPRISE_READY = false endif +BUILD_WEBAPP_DIR = ./webapp +# Golang Flags +GOPATH ?= $(GOPATH:) +GOFLAGS ?= $(GOFLAGS:) +GO=$(GOPATH)/bin/godep go +GO_LINKER_FLAGS ?= -ldflags \ + "-X github.com/mattermost/platform/model.BuildNumber=$(BUILD_NUMBER)\ + -X 'github.com/mattermost/platform/model.BuildDate=$(BUILD_DATE)'\ + -X github.com/mattermost/platform/model.BuildHash=$(BUILD_HASH)\ + -X github.com/mattermost/platform/model.BuildEnterpriseReady=$(BUILD_ENTERPRISE_READY)" + +# Output paths DIST_ROOT=dist DIST_PATH=$(DIST_ROOT)/mattermost +# Tests TESTS=. -DOCKERNAME ?= mm-dev -DOCKER_CONTAINER_NAME ?= mm-test - -all: dist-local - -dist: | build-server build-client go-test package - mv ./model/version.go.bak ./model/version.go - @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ - mv ./mattermost.go.bak ./mattermost.go; \ - mv ./config/config.json.bak ./config/config.json 2> /dev/null || true; \ - fi - -dist-local: | start-docker dist +all: dist -dist-travis: | travis-init build-container +dist: | check-style test package start-docker: @echo Starting docker containers @@ -66,107 +66,6 @@ start-docker: sleep 10; \ fi -build-server: - @echo Building mattermost server - - rm -Rf $(DIST_ROOT) - $(GO) clean $(GOFLAGS) -i ./... - - @echo GOFMT - $(eval GOFMT_OUTPUT := $(shell gofmt -d -s api/ model/ store/ utils/ manualtesting/ mattermost.go 2>&1)) - @echo "$(GOFMT_OUTPUT)" - @if [ ! "$(GOFMT_OUTPUT)" ]; then \ - echo "gofmt sucess"; \ - else \ - echo "gofmt failure"; \ - exit 1; \ - fi - - cp ./model/version.go ./model/version.go.bak - sed -i'.make_mac_work' 's|_BUILD_NUMBER_|$(BUILD_NUMBER)|g' ./model/version.go - sed -i'.make_mac_work' 's|_BUILD_DATE_|$(BUILD_DATE)|g' ./model/version.go - sed -i'.make_mac_work' 's|_BUILD_HASH_|$(BUILD_HASH)|g' ./model/version.go - - @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ - cp ./config/config.json ./config/config.json.bak; \ - jq -s '.[0] * .[1]' ./config/config.json $(ENTERPRISE_DIR)/config/enterprise-config-additions.json > config.json.tmp; \ - mv config.json.tmp ./config/config.json; \ - sed -e '/\/\/ENTERPRISE_IMPORTS/ {' -e 'r $(ENTERPRISE_DIR)/imports' -e 'd' -e '}' -i'.bak' mattermost.go; \ - sed -i'.make_mac_work' 's|_BUILD_ENTERPRISE_READY_|true|g' ./model/version.go; \ - else \ - sed -i'.make_mac_work' 's|_BUILD_ENTERPRISE_READY_|false|g' ./model/version.go; \ - fi - - rm ./model/version.go.make_mac_work - - $(GO) build $(GOFLAGS) ./... - $(GO) generate $(GOFLAGS) ./... - $(GO) install $(GOFLAGS) ./... - -package: - @ echo Packaging mattermost - - mkdir -p $(DIST_PATH)/bin - cp $(GOPATH)/bin/platform $(DIST_PATH)/bin - - cp -RL config $(DIST_PATH)/config - cp -RL fonts $(DIST_PATH)/fonts - touch $(DIST_PATH)/config/build.txt - echo $(BUILD_NUMBER) | tee -a $(DIST_PATH)/config/build.txt - - mkdir -p $(DIST_PATH)/logs - - mkdir -p $(DIST_PATH)/webapp/dist - cp -RL webapp/dist $(DIST_PATH)/webapp - - cp -RL templates $(DIST_PATH) - - cp -RL i18n $(DIST_PATH) - - cp build/MIT-COMPILED-LICENSE.md $(DIST_PATH) - cp NOTICE.txt $(DIST_PATH) - cp README.md $(DIST_PATH) - - mv $(DIST_PATH)/webapp/dist/bundle.js $(DIST_PATH)/webapp/dist/bundle-$(BUILD_NUMBER).js - sed -i'.bak' 's|bundle.js|bundle-$(BUILD_NUMBER).js|g' $(DIST_PATH)/webapp/dist/root.html - rm $(DIST_PATH)/webapp/dist/root.html.bak - - @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ - sudo mv -f $(DIST_PATH)/config/config.json.bak $(DIST_PATH)/config/config.json || echo 'nomv'; \ - fi - - tar -C dist -czf $(DIST_PATH).tar.gz mattermost - -build-client: - mkdir -p webapp/dist/files - cd webapp && make build - -go-test: - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s ./api || exit 1 - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=12s ./model || exit 1 - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./store || exit 1 - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./utils || exit 1 - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./web || exit 1 - -test: | start-docker .prepare-go go-test - -travis-init: - @echo Setting up enviroment for travis - - if [ "$(TRAVIS_DB)" = "postgres" ]; then \ - sed -i'.bak' 's|mysql|postgres|g' config/config.json; \ - sed -i'.bak2' 's|mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8|postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable\&connect_timeout=10|g' config/config.json; \ - fi - - if [ "$(TRAVIS_DB)" = "mysql" ]; then \ - sed -i'.bak' 's|mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8|mmuser:mostest@tcp(mysql:3306)/mattermost_test?charset=utf8mb4,utf8|g' config/config.json; \ - fi - -build-container: - @echo Building in container - - cd .. && docker run -e TRAVIS_BUILD_NUMBER=$(TRAVIS_BUILD_NUMBER) --link mattermost-mysql:mysql --link mattermost-postgres:postgres -v `pwd`:/go/src/github.com/mattermost mattermost/builder:latest - stop-docker: @echo Stopping docker containers @@ -195,92 +94,144 @@ clean-docker: docker rm -v mattermost-postgres > /dev/null; \ fi -clean: stop-docker - rm -Rf $(DIST_ROOT) - go clean $(GOFLAGS) -i ./... +check-style: + @echo Running GOFMT + $(eval GOFMT_OUTPUT := $(shell gofmt -d -s api/ model/ store/ utils/ manualtesting/ einterfaces/ mattermost.go 2>&1)) + @echo "$(GOFMT_OUTPUT)" + @if [ ! "$(GOFMT_OUTPUT)" ]; then \ + echo "gofmt sucess"; \ + else \ + echo "gofmt failure"; \ + exit 1; \ + fi - cd webapp && make clean +test: start-docker + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s ./api || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=12s ./model || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./store || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./utils || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./web || exit 1 - rm -rf api/data - rm -rf logs +.prebuild: + @echo Preparation for running go code + go get $(GOFLAGS) github.com/tools/godep - rm -rf Godeps/_workspace/pkg/ + touch $@ - rm -f mattermost.log - rm -f .prepare-go .prepare-jsx +prepare-enterprise: +ifeq ($(BUILD_ENTERPRISE_READY),true) + @echo Enterprise build selected, perparing + cp $(BUILD_ENTERPRISE_DIR)/imports.go . +endif -nuke: | clean clean-docker - rm -rf data +build: .prebuild prepare-enterprise + @echo Building mattermost server -.prepare-go: - @echo Preparation for running go code - go get $(GOFLAGS) github.com/tools/godep + $(GO) clean $(GOFLAGS) -i ./... + $(GO) install $(GOFLAGS) $(GO_LINKER_FLAGS) ./... - touch $@ +build-client: + @echo Building mattermost web app -run: | start-docker run-server run-client + cd $(BUILD_WEBAPP_DIR) && make build -run-server: .prepare-go - @echo Starting go web server - mkdir -p webapp/dist/files - $(GO) run $(GOFLAGS) mattermost.go -config=config.json & -run-client: - @echo Starting client +package: build build-client + @ echo Packaging mattermost - cd webapp && make run + # Remove any old files + rm -Rf $(DIST_ROOT) - @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ - cp ./config/config.json ./config/config.json.bak; \ - jq -s '.[0] * .[1]' ./config/config.json $(ENTERPRISE_DIR)/config/enterprise-config-additions.json > config.json.tmp; \ - mv config.json.tmp ./config/config.json; \ - sed -e '/\/\/ENTERPRISE_IMPORTS/ {' -e 'r $(ENTERPRISE_DIR)/imports' -e 'd' -e '}' -i'.bak' mattermost.go; \ - sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|true|g' ./model/version.go; \ - else \ - sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|false|g' ./model/version.go; \ - fi + # Create needed directories + mkdir -p $(DIST_PATH)/bin + mkdir -p $(DIST_PATH)/logs -stop: stop-server stop-client - @if [ $(shell docker ps -a | grep -ci ${DOCKER_CONTAINER_NAME}) -eq 1 ]; then \ - echo removing dev docker container; \ - docker stop ${DOCKER_CONTAINER_NAME} > /dev/null; \ - docker rm -v ${DOCKER_CONTAINER_NAME} > /dev/null; \ - fi + # Copy binary + cp $(GOPATH)/bin/platform $(DIST_PATH)/bin - @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ - mv ./config/config.json.bak ./config/config.json 2> /dev/null || true; \ - mv ./mattermost.go.bak ./mattermost.go 2> /dev/null || true; \ - mv ./model/version.go.bak ./model/version.go 2> /dev/null || true; \ - fi + # Resource directories + cp -RL config $(DIST_PATH) + cp -RL fonts $(DIST_PATH) + cp -RL templates $(DIST_PATH) + cp -RL i18n $(DIST_PATH) + + # Package webapp + mkdir -p $(DIST_PATH)/webapp/dist + cp -RL $(BUILD_WEBAPP_DIR)/dist $(DIST_PATH)/webapp + mv $(DIST_PATH)/webapp/dist/bundle.js $(DIST_PATH)/webapp/dist/bundle-$(BUILD_NUMBER).js + sed -i'.bak' 's|bundle.js|bundle-$(BUILD_NUMBER).js|g' $(DIST_PATH)/webapp/dist/root.html + rm $(DIST_PATH)/webapp/dist/root.html.bak + + # Help files +ifeq ($(BUILD_ENTERPRISE_READY),true) + cp $(BUILD_ENTERPRISE_DIR)/ENTERPRISE-EDITION-LICENSE.txt $(DIST_PATH) +else + cp build/MIT-COMPILED-LICENSE.md $(DIST_PATH) +endif + cp NOTICE.txt $(DIST_PATH) + cp README.md $(DIST_PATH) + + # Create package + tar -C dist -czf $(DIST_PATH).tar.gz mattermost + +run-server: prepare-enterprise start-docker + @echo Running mattermost for development + + mkdir -p $(BUILD_WEBAPP_DIR)/dist/files + $(GO) run $(GOFLAGS) $(GO_LINKER_FLAGS) *.go & + +run-client: + @echo Running mattermost client for development + + cd $(BUILD_WEBAPP_DIR) && make run + +run: run-server run-client stop-server: - @for PID in $$(ps -ef | grep "go run [m]attermost.go" | awk '{ print $$2 }'); do \ + @echo Stopping mattermost + + @for PID in $$(ps -ef | grep "[g]o run" | awk '{ print $$2 }'); do \ echo stopping go $$PID; \ kill $$PID; \ done - @for PID in $$(ps -ef | grep "go-build.*/[m]attermost" | awk '{ print $$2 }'); do \ + @for PID in $$(ps -ef | grep "[g]o-build" | awk '{ print $$2 }'); do \ echo stopping mattermost $$PID; \ kill $$PID; \ done stop-client: - cd webapp && make stop + @echo Stopping mattermost client -restart-server: stop-server run-server + cd $(BUILD_WEBAPP_DIR) && make stop -setup-mac: - echo $$(boot2docker ip 2> /dev/null) dockerhost | sudo tee -a /etc/hosts -cleandb: - @if [ $(shell docker ps -a | grep -ci mattermost-mysql) -eq 1 ]; then \ - docker stop mattermost-mysql > /dev/null; \ - docker rm -v mattermost-mysql > /dev/null; \ - fi +stop: stop-server stop-client + +restart-server: | stop-server run-server + +restart-client: | stop-client run-client + +clean: stop-docker + @echo Cleaning + + rm -Rf $(DIST_ROOT) + go clean $(GOFLAGS) -i ./... + + cd $(BUILD_WEBAPP_DIR) && make clean -docker-build: stop - docker build -t ${DOCKERNAME} -f docker/local/Dockerfile . + rm -rf api/data + rm -rf logs + + rm -rf Godeps/_workspace/pkg/ + + rm -f mattermost.log + rm -f .prepare-go + +nuke: clean clean-docker + @echo BOOM -docker-run: docker-build - docker run --name ${DOCKER_CONTAINER_NAME} -d --publish 8065:80 ${DOCKERNAME} + rm -rf data +setup-mac: + echo $$(boot2docker ip 2> /dev/null) dockerhost | sudo tee -a /etc/hosts diff --git a/api/webhook.go b/api/webhook.go index c0f8ea506..fe1aa1175 100644 --- a/api/webhook.go +++ b/api/webhook.go @@ -4,11 +4,14 @@ package api import ( + "net/http" + "strings" + l4g "github.com/alecthomas/log4go" "github.com/gorilla/mux" "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" - "net/http" ) func InitWebhook(r *mux.Router) { @@ -23,6 +26,12 @@ func InitWebhook(r *mux.Router) { sr.Handle("/outgoing/regen_token", ApiUserRequired(regenOutgoingHookToken)).Methods("POST") sr.Handle("/outgoing/delete", ApiUserRequired(deleteOutgoingHook)).Methods("POST") sr.Handle("/outgoing/list", ApiUserRequired(getOutgoingHooks)).Methods("GET") + + sr.Handle("/{id:[A-Za-z0-9]+}", ApiAppHandler(incomingWebhook)).Methods("POST") + + // Old route. Remove eventually. + mr := Srv.Router + mr.Handle("/hooks/{id:[A-Za-z0-9]+}", ApiAppHandler(incomingWebhook)).Methods("POST") } func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { @@ -330,3 +339,105 @@ func regenOutgoingHookToken(c *Context, w http.ResponseWriter, r *http.Request) w.Write([]byte(result.Data.(*model.OutgoingWebhook).ToJson())) } } + +func incomingWebhook(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + params := mux.Vars(r) + id := params["id"] + + hchan := Srv.Store.Webhook().GetIncoming(id) + + r.ParseForm() + + var parsedRequest *model.IncomingWebhookRequest + contentType := r.Header.Get("Content-Type") + if strings.Split(contentType, "; ")[0] == "application/json" { + parsedRequest = model.IncomingWebhookRequestFromJson(r.Body) + } else { + parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload"))) + } + + if parsedRequest == nil { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.parse.app_error", nil, "") + return + } + + text := parsedRequest.Text + if len(text) == 0 && parsedRequest.Attachments == nil { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.text.app_error", nil, "") + return + } + + channelName := parsedRequest.ChannelName + webhookType := parsedRequest.Type + + //attachments is in here for slack compatibility + if parsedRequest.Attachments != nil { + if len(parsedRequest.Props) == 0 { + parsedRequest.Props = make(model.StringInterface) + } + parsedRequest.Props["attachments"] = parsedRequest.Attachments + webhookType = model.POST_SLACK_ATTACHMENT + } + + var hook *model.IncomingWebhook + if result := <-hchan; result.Err != nil { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.invalid.app_error", nil, "err="+result.Err.Message) + return + } else { + hook = result.Data.(*model.IncomingWebhook) + } + + var channel *model.Channel + var cchan store.StoreChannel + + if len(channelName) != 0 { + if channelName[0] == '@' { + if result := <-Srv.Store.User().GetByUsername(hook.TeamId, channelName[1:]); result.Err != nil { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.user.app_error", nil, "err="+result.Err.Message) + return + } else { + channelName = model.GetDMNameFromIds(result.Data.(*model.User).Id, hook.UserId) + } + } else if channelName[0] == '#' { + channelName = channelName[1:] + } + + cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName) + } else { + cchan = Srv.Store.Channel().Get(hook.ChannelId) + } + + overrideUsername := parsedRequest.Username + overrideIconUrl := parsedRequest.IconURL + + if result := <-cchan; result.Err != nil { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message) + return + } else { + channel = result.Data.(*model.Channel) + } + + pchan := Srv.Store.Channel().CheckPermissionsTo(hook.TeamId, channel.Id, hook.UserId) + + // create a mock session + c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false} + + if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN { + c.Err = model.NewLocAppError("incomingWebhook", "web.incoming_webhook.permissions.app_error", nil, "") + return + } + + if _, err := CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl, parsedRequest.Props, webhookType); err != nil { + c.Err = err + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("ok")) +} diff --git a/config/config.json b/config/config.json index 1735ca293..65a61bb72 100644 --- a/config/config.json +++ b/config/config.json @@ -87,8 +87,8 @@ "InviteSalt": "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9YoS", "PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL", "SendPushNotifications": false, - "PushNotificationContents": "generic", - "PushNotificationServer": "" + "PushNotificationServer": "", + "PushNotificationContents": "generic" }, "RateLimitSettings": { "EnableRateLimiter": true, @@ -146,4 +146,4 @@ "Directory": "./data/", "EnableDaily": false } -} +}
\ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index 6ba877f56..3180e8dbc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3189,7 +3189,7 @@ }, { "id": "store.sql_user.save.max_accounts.app_error", - "translation": "This team has reached the maxmium number of allowed accounts. Contact your systems administrator to set a higher limit." + "translation": "This team has reached the maximum number of allowed accounts. Contact your systems administrator to set a higher limit." }, { "id": "store.sql_user.save.member_count.app_error", diff --git a/i18n/pt.json b/i18n/pt.json index c59eb896b..15bc42835 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -1016,6 +1016,10 @@ "translation": "Somente um administrador de equipe pode exportar os dados." }, { + "id": "api.team.get_invite_info.not_open_team", + "translation": "Convite é inválido devido a este não ser de uma equipe aberta." + }, + { "id": "api.team.import_team.admin.app_error", "translation": "Somente um administrador de equipe pode importar dados." }, @@ -1704,6 +1708,26 @@ "translation": "Permissões inadequadas para re-gerar o token do webhook de saída" }, { + "id": "ent.compliance.licence_disable.app_error", + "translation": "Funcionalidade Compliance desabilitada pela licença atual. Entre em contato com o administrador do sistema sobre como atualizar sua licença de empresa." + }, + { + "id": "ent.compliance.run_failed.error", + "translation": "Falha na tarefa '{{.JobName}}' para exportar o compliance no '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_finished.info", + "translation": "Exportação do compliance terminado para tarefa '{{.JobName}}' exportado {{.Count}} registros para '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_limit.warning", + "translation": "Aviso exportação do compliance na tarefa '{{.JobName}}' retornando muitos registros truncando para 30.000 em '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_started.info", + "translation": "Exportação de compliance tarefa '{{.JobName}}' iniciada no '{{.FilePath}}'" + }, + { "id": "ent.ldap.do_login.bind_admin_user.app_error", "translation": "Não foi possível ligar ao servidor LDAP. Verifique BindUsername e BindPassword." }, @@ -1841,7 +1865,7 @@ }, { "id": "model.authorize.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.authorize.is_valid.expires.app_error", @@ -1869,7 +1893,7 @@ }, { "id": "model.channel.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.channel.is_valid.creator_id.app_error", @@ -1901,7 +1925,7 @@ }, { "id": "model.channel.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.channel_member.is_valid.channel_id.app_error", @@ -1933,7 +1957,7 @@ }, { "id": "model.command.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.command.is_valid.id.app_error", @@ -1957,7 +1981,7 @@ }, { "id": "model.command.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.command.is_valid.url.app_error", @@ -1972,6 +1996,30 @@ "translation": "ID de usuário inválido" }, { + "id": "model.compliance.is_valid.create_at.app_error", + "translation": "Create deve ser um tempo válido" + }, + { + "id": "model.compliance.is_valid.desc.app_error", + "translation": "Descrição inválida" + }, + { + "id": "model.compliance.is_valid.end_at.app_error", + "translation": "Para precisa ser um tempo válido" + }, + { + "id": "model.compliance.is_valid.id.app_error", + "translation": "ID inválida" + }, + { + "id": "model.compliance.is_valid.start_at.app_error", + "translation": "De precisa ser um tempo válido" + }, + { + "id": "model.compliance.is_valid.start_end_at.app_error", + "translation": "Para precisa ser maior que De" + }, + { "id": "model.config.is_valid.email_reset_salt.app_error", "translation": "Senha inválida redefinir salt nas configurações de email. Deve ser de 32 caracteres ou mais." }, @@ -2065,7 +2113,7 @@ }, { "id": "model.incoming_hook.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.incoming_hook.id.app_error", @@ -2077,7 +2125,7 @@ }, { "id": "model.incoming_hook.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.incoming_hook.user_id.app_error", @@ -2097,7 +2145,7 @@ }, { "id": "model.oauth.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.oauth.is_valid.creator_id.app_error", @@ -2117,7 +2165,7 @@ }, { "id": "model.oauth.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.outgoing_hook.is_valid.callback.app_error", @@ -2129,7 +2177,7 @@ }, { "id": "model.outgoing_hook.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.outgoing_hook.is_valid.id.app_error", @@ -2145,7 +2193,7 @@ }, { "id": "model.outgoing_hook.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.outgoing_hook.is_valid.url.app_error", @@ -2165,7 +2213,7 @@ }, { "id": "model.post.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.post.is_valid.filenames.app_error", @@ -2209,7 +2257,7 @@ }, { "id": "model.post.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.post.is_valid.user_id.app_error", @@ -2241,7 +2289,7 @@ }, { "id": "model.team.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.team.is_valid.domains.app_error", @@ -2269,7 +2317,7 @@ }, { "id": "model.team.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.team.is_valid.url.app_error", @@ -2289,7 +2337,7 @@ }, { "id": "model.user.is_valid.create_at.app_error", - "translation": "Deve-se criar em um tempo válido" + "translation": "Create deve ser um tempo válido" }, { "id": "model.user.is_valid.email.app_error", @@ -2325,7 +2373,7 @@ }, { "id": "model.user.is_valid.update_at.app_error", - "translation": "Deve-se atualizar em um tempo válido" + "translation": "Update deve ser um tempo válido" }, { "id": "model.user.is_valid.username.app_error", @@ -2704,6 +2752,14 @@ "translation": "Não foi possível atualizar o comando" }, { + "id": "store.sql_compliance.get.finding.app_error", + "translation": "Encontramos um erro ao obter o relatório de compliance" + }, + { + "id": "store.sql_compliance.save.saving.app_error", + "translation": "Encontramos um erro ao salvar o relatório de compliance" + }, + { "id": "store.sql_license.get.app_error", "translation": "Encontramos um erro ao obter a licença" }, @@ -2800,6 +2856,10 @@ "translation": "Não foi possível obter o número de posts" }, { + "id": "store.sql_post.compliance_export.app_error", + "translation": "Não foi possível obter os posts para exportação compliance" + }, + { "id": "store.sql_post.delete.app_error", "translation": "Não foi possível deletar o post" }, @@ -3076,6 +3136,10 @@ "translation": "Não foi possível encontrar uma conta correspondente ao seu tipo de autenticação para esta equipe. Esta equipe pode exigir um convite do dono da equipe para participar." }, { + "id": "store.sql_user.get_by_auth.other.app_error", + "translation": "Foi encontrado um erro ao tentar encontrar a conta pelo tipo de autenticação." + }, + { "id": "store.sql_user.get_by_username.app_error", "translation": "Não foi possível encontrar uma conta correspondente ao seu usuário para esta equipe. Esta equipe pode exigir um convite do dono da equipe para participar." }, @@ -3100,6 +3164,10 @@ "translation": "Não foi possível contar os usuários" }, { + "id": "store.sql_user.get_unread_count.app_error", + "translation": "Não foi possível obter o número de mensagens não lidas para o usuário" + }, + { "id": "store.sql_user.missing_account.const", "translation": "Não foi possível encontrar uma conta correspondente ao seu endereço de email para esta equipe. Esta equipe pode exigir um convite do dono da equipe para participar." }, @@ -3590,9 +3658,5 @@ { "id": "web.watcher_fail.error", "translation": "Falha ao adicionar diretório observador %v" - }, - { - "id": "api.team.get_invite_info.not_open_team", - "translation": "Convite é inválido devido a este não ser de uma equipe aberta." } -] +]
\ No newline at end of file diff --git a/model/version.go b/model/version.go index 737071934..b3950dcc9 100644 --- a/model/version.go +++ b/model/version.go @@ -28,10 +28,10 @@ var versions = []string{ } var CurrentVersion string = versions[0] -var BuildNumber = "_BUILD_NUMBER_" -var BuildDate = "_BUILD_DATE_" -var BuildHash = "_BUILD_HASH_" -var BuildEnterpriseReady = "_BUILD_ENTERPRISE_READY_" +var BuildNumber string +var BuildDate string +var BuildHash string +var BuildEnterpriseReady string var versionsWithoutHotFixes []string func init() { diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 68c22f7f6..401306862 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -38,11 +38,6 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore { } func (s SqlPostStore) UpgradeSchemaIfNeeded() { - // ADDED for 1.3 REMOVE for 2.2 - s.RemoveColumnIfExists("Posts", "ImgCount") - - // ADDED for 1.3 REMOVE for 2.2 - s.GetMaster().Exec(`UPDATE Preferences SET Type = :NewType WHERE Type = :CurrentType`, map[string]string{"NewType": model.POST_JOIN_LEAVE, "CurrentType": "join_leave"}) } func (s SqlPostStore) CreateIndexesIfNotExists() { diff --git a/web/web_test.go b/web/web_test.go index 8305ca34e..8dde5d747 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -4,7 +4,6 @@ package web import ( - "net/http" "net/url" "strings" "testing" @@ -40,6 +39,7 @@ func TearDown() { } } +/* Test disabled for now so we don't requrie the client to build. Maybe re-enable after client gets moved out. func TestStatic(t *testing.T) { Setup() @@ -54,6 +54,7 @@ func TestStatic(t *testing.T) { t.Fatalf("couldn't get static files %v", resp.StatusCode) } } +*/ func TestGetAccessToken(t *testing.T) { Setup() diff --git a/webapp/action_creators/global_actions.jsx b/webapp/action_creators/global_actions.jsx index 0280d5974..ab38532a6 100644 --- a/webapp/action_creators/global_actions.jsx +++ b/webapp/action_creators/global_actions.jsx @@ -10,6 +10,7 @@ const ActionTypes = Constants.ActionTypes; import * as AsyncClient from 'utils/async_client.jsx'; import * as Client from 'utils/client.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as Websockets from './websocket_actions.jsx'; import * as I18n from 'i18n/i18n.jsx'; import en from 'i18n/en.json'; @@ -97,10 +98,21 @@ export function emitLoadMorePostsFocusedBottomEvent() { AsyncClient.getPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE); } -export function emitPostRecievedEvent(post) { +export function emitPostRecievedEvent(post, websocketMessageProps) { + if (ChannelStore.getCurrentId() === post.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(); + } else { + AsyncClient.getChannel(post.channel_id); + } + } else { + AsyncClient.getChannel(post.channel_id); + } + AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_POST, - post + post, + websocketMessageProps }); } @@ -261,3 +273,21 @@ export function viewLoggedIn() { // Clear pending posts (shouldn't have pending posts if we are loading) PostStore.clearPendingPosts(); } + +var lastTimeTypingSent = 0; +export function emitLocalUserTypingEvent(channelId, parentId) { + const t = Date.now(); + if ((t - lastTimeTypingSent) > Constants.UPDATE_TYPING_MS) { + Websockets.sendMessage({channel_id: channelId, action: 'typing', props: {parent_id: parentId}, state: {}}); + lastTimeTypingSent = t; + } +} + +export function emitRemoteUserTypingEvent(channelId, userId, postParentId) { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.USER_TYPING, + channelId, + userId, + postParentId + }); +} diff --git a/webapp/action_creators/websocket_actions.jsx b/webapp/action_creators/websocket_actions.jsx new file mode 100644 index 000000000..55a76dbea --- /dev/null +++ b/webapp/action_creators/websocket_actions.jsx @@ -0,0 +1,227 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import UserStore from 'stores/user_store.jsx'; +import PostStore from 'stores/post_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import BrowserStore from 'stores/browser_store.jsx'; +import ErrorStore from 'stores/error_store.jsx'; + +import * as Utils from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; + +import Constants from 'utils/constants.jsx'; +const SocketEvents = Constants.SocketEvents; + +const MAX_WEBSOCKET_FAILS = 7; +const WEBSOCKET_RETRY_TIME = 3000; + +var conn = null; +var connectFailCount = 0; +var pastFirstInit = false; + +export function initialize() { + if (window.WebSocket && !conn) { + let protocol = 'ws://'; + if (window.location.protocol === 'https:') { + protocol = 'wss://'; + } + + const connUrl = protocol + location.host + ((/:\d+/).test(location.host) ? '' : Utils.getWebsocketPort(protocol)) + '/api/v1/websocket'; + + if (connectFailCount === 0) { + console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console + } + + conn = new WebSocket(connUrl); + + conn.onopen = () => { + if (connectFailCount > 0) { + console.log('websocket re-established connection'); //eslint-disable-line no-console + AsyncClient.getChannels(); + AsyncClient.getPosts(ChannelStore.getCurrentId()); + } + + if (pastFirstInit) { + ErrorStore.clearLastError(); + ErrorStore.emitChange(); + } + + pastFirstInit = true; + connectFailCount = 0; + }; + + conn.onclose = () => { + conn = null; + + if (connectFailCount === 0) { + console.log('websocket closed'); //eslint-disable-line no-console + } + + connectFailCount = connectFailCount + 1; + + if (connectFailCount > MAX_WEBSOCKET_FAILS) { + ErrorStore.storeLastError(Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')); + } + + ErrorStore.setConnectionErrorCount(connectFailCount); + ErrorStore.emitChange(); + + setTimeout( + () => { + initialize(); + }, + WEBSOCKET_RETRY_TIME + ); + }; + + conn.onerror = (evt) => { + if (connectFailCount <= 1) { + console.log('websocket error'); //eslint-disable-line no-console + console.log(evt); //eslint-disable-line no-console + } + }; + + conn.onmessage = (evt) => { + const msg = JSON.parse(evt.data); + handleMessage(msg); + }; + } +} + +function handleMessage(msg) { + // Let the store know we are online. This probably shouldn't be here. + UserStore.setStatus(msg.user_id, 'online'); + + switch (msg.action) { + case SocketEvents.POSTED: + case SocketEvents.EPHEMERAL_MESSAGE: + handleNewPostEvent(msg); + break; + + case SocketEvents.POST_EDITED: + handlePostEditEvent(msg); + break; + + case SocketEvents.POST_DELETED: + handlePostDeleteEvent(msg); + break; + + case SocketEvents.NEW_USER: + handleNewUserEvent(); + break; + + case SocketEvents.USER_ADDED: + handleUserAddedEvent(msg); + break; + + case SocketEvents.USER_REMOVED: + handleUserRemovedEvent(msg); + break; + + case SocketEvents.CHANNEL_VIEWED: + handleChannelViewedEvent(msg); + break; + + case SocketEvents.PREFERENCE_CHANGED: + handlePreferenceChangedEvent(msg); + break; + + case SocketEvents.TYPING: + handleUserTypingEvent(msg); + break; + + default: + } +} + +export function sendMessage(msg) { + if (conn && conn.readyState === WebSocket.OPEN) { + conn.send(JSON.stringify(msg)); + } else if (!conn || conn.readyState === WebSocket.Closed) { + conn = null; + this.initialize(); + } +} + +export function close() { + if (conn && conn.readyState === WebSocket.OPEN) { + conn.close(); + } +} + +function handleNewPostEvent(msg) { + const post = JSON.parse(msg.props.post); + GlobalActions.emitPostRecievedEvent(post, msg.props); +} + +function handlePostEditEvent(msg) { + // Store post + const post = JSON.parse(msg.props.post); + PostStore.storePost(post); + PostStore.emitChange(); + + // Update channel state + if (ChannelStore.getCurrentId() === msg.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(); + } + } +} + +function handlePostDeleteEvent(msg) { + const post = JSON.parse(msg.props.post); + GlobalActions.emitPostDeletedEvent(post); +} + +function handleNewUserEvent() { + AsyncClient.getProfiles(); + AsyncClient.getChannelExtraInfo(); +} + +function handleUserAddedEvent(msg) { + if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(); + } + + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + +function handleUserRemovedEvent(msg) { + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannels(); + + if (msg.props.remover_id !== msg.user_id && + msg.channel_id === ChannelStore.getCurrentId() && + $('#removed_from_channel').length > 0) { + var sentState = {}; + sentState.channelName = ChannelStore.getCurrent().display_name; + sentState.remover = UserStore.getProfile(msg.props.remover_id).username; + + BrowserStore.setItem('channel-removed-state', sentState); + $('#removed_from_channel').modal('show'); + } + } else if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(); + } +} + +function handleChannelViewedEvent(msg) { + // Useful for when multiple devices have the app open to different channels + if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + +function handlePreferenceChangedEvent(msg) { + const preference = JSON.parse(msg.props.preference); + GlobalActions.emitPreferenceChangedEvent(preference); +} + +function handleUserTypingEvent(msg) { + GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.props.parent_id); +} diff --git a/webapp/components/about_build_modal.jsx b/webapp/components/about_build_modal.jsx index e2fefc44e..e73d842d0 100644 --- a/webapp/components/about_build_modal.jsx +++ b/webapp/components/about_build_modal.jsx @@ -24,7 +24,7 @@ export default class AboutBuildModal extends React.Component { let title = ( <FormattedMessage id='about.teamEditiont0' - defaultMessage='Team Edition T0' + defaultMessage='Team Edition' /> ); @@ -33,14 +33,14 @@ export default class AboutBuildModal extends React.Component { title = ( <FormattedMessage id='about.teamEditiont1' - defaultMessage='Team Edition T1' + defaultMessage='Enterprise Edition' /> ); if (license.IsLicensed === 'true') { title = ( <FormattedMessage id='about.enterpriseEditione1' - defaultMessage='Enterprise Edition E1' + defaultMessage='Enterprise Edition' /> ); licensee = ( diff --git a/webapp/components/access_history_modal.jsx b/webapp/components/access_history_modal.jsx index 94a10c97f..9c49c3879 100644 --- a/webapp/components/access_history_modal.jsx +++ b/webapp/components/access_history_modal.jsx @@ -2,7 +2,6 @@ // See License.txt for license information. import $ from 'jquery'; -import ReactDOM from 'react-dom'; import {Modal} from 'react-bootstrap'; import LoadingScreen from './loading_screen.jsx'; import AuditTable from './audit_table.jsx'; @@ -36,11 +35,8 @@ class AccessHistoryModal extends React.Component { } onShow() { AsyncClient.getAudits(); - - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } else { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); + if (!Utils.isMobile()) { + $('.modal-body').perfectScrollbar(); } } onHide() { diff --git a/webapp/components/activity_log_modal.jsx b/webapp/components/activity_log_modal.jsx index 9a4ff3ef2..f1dd4a26a 100644 --- a/webapp/components/activity_log_modal.jsx +++ b/webapp/components/activity_log_modal.jsx @@ -2,7 +2,6 @@ // See License.txt for license information. import $ from 'jquery'; -import ReactDOM from 'react-dom'; import UserStore from 'stores/user_store.jsx'; import * as Client from 'utils/client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; @@ -56,11 +55,8 @@ export default class ActivityLogModal extends React.Component { } onShow() { AsyncClient.getSessions(); - - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } else { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); + if (!Utils.isMobile()) { + $('.modal-body').perfectScrollbar(); } } onHide() { diff --git a/webapp/components/admin_console/license_settings.jsx b/webapp/components/admin_console/license_settings.jsx index 5aa0dba7e..ad310d8e0 100644 --- a/webapp/components/admin_console/license_settings.jsx +++ b/webapp/components/admin_console/license_settings.jsx @@ -105,36 +105,27 @@ class LicenseSettings extends React.Component { let licenseType; let licenseKey; + const issued = Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true); + const startsAt = Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10)); + const expiresAt = Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10)); + if (global.window.mm_license.IsLicensed === 'true') { - edition = ( - <FormattedMessage - id='admin.license.enterpriseEdition' - defaultMessage='Mattermost Enterprise Edition. Designed for enterprise-scale communication.' - /> - ); + // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. + edition = 'Mattermost Enterprise Edition. Enterprise features on this server have been unlocked with a license key and a valid subscription.'; licenseType = ( - <FormattedHTMLMessage - id='admin.license.enterpriseType' - values={{ - terms: global.window.mm_config.TermsOfServiceLink, - name: global.window.mm_license.Name, - company: global.window.mm_license.Company, - users: global.window.mm_license.Users, - issued: Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true), - start: Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10)), - expires: Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10)), - ldap: global.window.mm_license.LDAP - }} - defaultMessage='<div><p>This compiled release of Mattermost platform is provided under a <a href="http://mattermost.com" target="_blank">commercial license</a> from Mattermost, Inc. based on your subscription level and is subject to the <a href="{terms}" target="_blank">Terms of Service.</a></p> - <p>Your subscription details are as follows:</p> - Name: {name}<br /> - Company or organization name: {company}<br/> - Number of users: {users}<br/> - License issued: {issued}<br/> - Start date of license: {start}<br/> - Expiry date of license: {expires}<br/> - LDAP: {ldap}<br/></div>' - /> + <div> + <p> + {'This software is offered under a commercial license.\n\nSee ENTERPRISE-EDITION-LICENSE.txt in your root install directory for details. See NOTICE.txt for information about open source software used in this system.\n\nYour subscription details are as follows:'} + </p> + {`Name: ${global.window.mm_license.Name}`}<br/> + {`Company or organization name: ${global.window.mm_license.Company}`}<br/> + {`Number of users: ${global.window.mm_license.Users}`}<br/> + {`License issued: ${issued}`}<br/> + {`Start date of license: ${startsAt}`}<br/> + {`Expiry date of license: ${expiresAt}`}<br/> + <br/> + {'See also '}<a href='https://about.mattermost.com/enterprise-edition-terms/'>{'Enterprise Edition Terms of Service'}</a>{' and '}<a href='https://about.mattermost.com/privacy/'>{'Privacy Policy.'}</a> + </div> ); licenseKey = ( @@ -162,20 +153,15 @@ class LicenseSettings extends React.Component { </div> ); } else { + // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. edition = ( - <FormattedMessage - id='admin.license.teamEdition' - defaultMessage='Mattermost Team Edition. Designed for teams from 5 to 50 users.' - /> + <p> + {'Mattermost Enterprise Edition. Unlock enterprise features in this software through the purchase of a subscription from '} + <a href='https://mattermost.com/'>{'https://mattermost.com/'}</a> + </p> ); - licenseType = ( - <FormattedHTMLMessage - id='admin.license.teamType' - defaultMessage='<span><p>This compiled release of Mattermost platform is offered under an MIT license.</p> - <p>See MIT-COMPILED-LICENSE.txt in your root install directory for details. See NOTICES.txt for information about open source software used in this system.</p></span>' - /> - ); + licenseType = 'This software is offered under a commercial license.\n\nSee ENTERPRISE-EDITION-LICENSE.txt in your root install directory for details. See NOTICE.txt for information about open source software used in this system.'; let fileName; if (this.state.fileName) { diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 7cd713942..369fa2dbb 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -80,6 +80,7 @@ export default class ChannelHeader extends React.Component { SearchStore.addSearchChangeListener(this.onListenerChange); PreferenceStore.addChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); + $('.sidebar--left .dropdown-menu').perfectScrollbar(); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); diff --git a/webapp/components/channel_invite_button.jsx b/webapp/components/channel_invite_button.jsx new file mode 100644 index 000000000..e4af9f9ce --- /dev/null +++ b/webapp/components/channel_invite_button.jsx @@ -0,0 +1,79 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Client from 'utils/client.jsx'; + +import {FormattedMessage} from 'react-intl'; +import SpinnerButton from 'components/spinner_button.jsx'; + +export default class ChannelInviteButton extends React.Component { + static get propTypes() { + return { + user: React.PropTypes.object.isRequired, + channel: React.PropTypes.object.isRequired, + onInviteError: React.PropTypes.func.isRequired + }; + } + + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + + this.state = { + addingUser: false + }; + } + + handleClick() { + if (this.state.addingUser) { + return; + } + + this.setState({ + addingUser: true + }); + + const data = { + user_id: this.props.user.id + }; + + Client.addChannelMember( + this.props.channel.id, + data, + () => { + this.setState({ + addingUser: false + }); + + this.props.onInviteError(null); + AsyncClient.getChannelExtraInfo(); + }, + (err) => { + this.setState({ + addingUser: false + }); + + this.props.onInviteError(err); + } + ); + } + + render() { + return ( + <SpinnerButton + onClick={this.handleClick} + spinning={this.state.addingUser} + > + <i className='glyphicon glyphicon-envelope'/> + <FormattedMessage + id='channel_invite.add' + defaultMessage=' Add' + /> + </SpinnerButton> + ); + } +} diff --git a/webapp/components/channel_invite_modal.jsx b/webapp/components/channel_invite_modal.jsx index dfb0d4187..c7c1906a5 100644 --- a/webapp/components/channel_invite_modal.jsx +++ b/webapp/components/channel_invite_modal.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import $ from 'jquery'; +import ChannelInviteButton from './channel_invite_button.jsx'; import FilteredUserList from './filtered_user_list.jsx'; import LoadingScreen from './loading_screen.jsx'; @@ -9,7 +10,6 @@ import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; -import * as Client from 'utils/client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import {FormattedMessage} from 'react-intl'; @@ -23,9 +23,8 @@ export default class ChannelInviteModal extends React.Component { super(props); this.onListenerChange = this.onListenerChange.bind(this); - this.handleInvite = this.handleInvite.bind(this); this.getStateFromStores = this.getStateFromStores.bind(this); - this.createInviteButton = this.createInviteButton.bind(this); + this.handleInviteError = this.handleInviteError.bind(this); this.state = this.getStateFromStores(); } @@ -120,36 +119,16 @@ export default class ChannelInviteModal extends React.Component { this.setState(newState); } } - handleInvite(user) { - const data = { - user_id: user.id - }; - - Client.addChannelMember( - this.props.channel.id, - data, - () => { - this.setState({inviteError: null}); - AsyncClient.getChannelExtraInfo(); - }, - (err) => { - this.setState({inviteError: err.message}); - } - ); - } - createInviteButton({user}) { - return ( - <a - onClick={this.handleInvite.bind(this, user)} - className='btn btn-sm btn-primary' - > - <i className='glyphicon glyphicon-envelope'/> - <FormattedMessage - id='channel_invite.add' - defaultMessage=' Add' - /> - </a> - ); + handleInviteError(err) { + if (err) { + this.setState({ + inviteError: err.message + }); + } else { + this.setState({ + inviteError: null + }); + } } render() { var inviteError = null; @@ -169,7 +148,11 @@ export default class ChannelInviteModal extends React.Component { <FilteredUserList style={{maxHeight}} users={this.state.nonmembers} - actions={[this.createInviteButton]} + actions={[ChannelInviteButton]} + actionProps={{ + channel: this.props.channel, + onInviteError: this.handleInviteError + }} /> ); } diff --git a/webapp/components/create_comment.jsx b/webapp/components/create_comment.jsx index 0aeb70c57..177f282d3 100644 --- a/webapp/components/create_comment.jsx +++ b/webapp/components/create_comment.jsx @@ -6,7 +6,6 @@ import ReactDOM from 'react-dom'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as Client from 'utils/client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; -import SocketStore from 'stores/socket_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PostDeletedModal from './post_deleted_modal.jsx'; @@ -17,6 +16,7 @@ import MsgTyping from './msg_typing.jsx'; import FileUpload from './file_upload.jsx'; import FilePreview from './file_preview.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import Constants from 'utils/constants.jsx'; @@ -196,11 +196,7 @@ class CreateComment extends React.Component { } } - const t = Date.now(); - 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; - } + GlobalActions.emitLocalUserTypingEvent(this.props.channelId, this.props.rootId); } handleUserInput(messageText) { let draft = PostStore.getCommentDraft(this.props.rootId); diff --git a/webapp/components/create_post.jsx b/webapp/components/create_post.jsx index 36bfbf22d..e5e99debd 100644 --- a/webapp/components/create_post.jsx +++ b/webapp/components/create_post.jsx @@ -19,7 +19,6 @@ import ChannelStore from 'stores/channel_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; -import SocketStore from 'stores/socket_store.jsx'; import Constants from 'utils/constants.jsx'; @@ -213,11 +212,7 @@ class CreatePost extends React.Component { } } - const t = Date.now(); - if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) { - SocketStore.sendMessage({channel_id: this.state.channelId, action: 'typing', props: {parent_id: ''}, state: {}}); - this.lastTime = t; - } + GlobalActions.emitLocalUserTypingEvent(this.state.channelId, ''); } handleUserInput(messageText) { this.setState({messageText}); diff --git a/webapp/components/filtered_user_list.jsx b/webapp/components/filtered_user_list.jsx index 1e4afd2be..bd6c49714 100644 --- a/webapp/components/filtered_user_list.jsx +++ b/webapp/components/filtered_user_list.jsx @@ -114,6 +114,7 @@ class FilteredUserList extends React.Component { <UserList users={users} actions={this.props.actions} + actionProps={this.props.actionProps} /> </div> </div> @@ -123,13 +124,15 @@ class FilteredUserList extends React.Component { FilteredUserList.defaultProps = { users: [], - actions: [] + actions: [], + actionProps: {} }; FilteredUserList.propTypes = { intl: intlShape.isRequired, users: React.PropTypes.arrayOf(React.PropTypes.object), actions: React.PropTypes.arrayOf(React.PropTypes.func), + actionProps: React.PropTypes.object, style: React.PropTypes.object }; diff --git a/webapp/components/invite_member_modal.jsx b/webapp/components/invite_member_modal.jsx index d567183ac..1f8fd6133 100644 --- a/webapp/components/invite_member_modal.jsx +++ b/webapp/components/invite_member_modal.jsx @@ -1,7 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; import ReactDOM from 'react-dom'; import * as utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; @@ -176,12 +175,6 @@ class InviteMemberModal extends React.Component { }); } - componentDidUpdate(prevProps, prevState) { - if (!prevState.show && this.state.show) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } - } - addInviteFields() { var count = this.state.idCount + 1; var inviteIds = this.state.inviteIds; diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx index 6d35ff8c2..c6f7b50b1 100644 --- a/webapp/components/logged_in.jsx +++ b/webapp/components/logged_in.jsx @@ -5,12 +5,13 @@ import $ from 'jquery'; import * as AsyncClient from 'utils/async_client.jsx'; import * as GlobalActions from 'action_creators/global_actions.jsx'; import UserStore from 'stores/user_store.jsx'; -import SocketStore from 'stores/socket_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import BrowserStore from 'stores/browser_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; import ErrorBar from 'components/error_bar.jsx'; +import * as Websockets from 'action_creators/websocket_actions.jsx'; import {browserHistory} from 'react-router'; @@ -66,11 +67,6 @@ export default class LoggedIn extends React.Component { } } } - onSocketChange(msg) { - if (msg && msg.user_id && msg.user_id !== UserStore.getCurrentId()) { - UserStore.setStatus(msg.user_id, 'online'); - } - } componentWillMount() { // Emit view action GlobalActions.viewLoggedIn(); @@ -78,8 +74,8 @@ export default class LoggedIn extends React.Component { // Listen for user UserStore.addChangeListener(this.onUserChanged); - // Add listner for socker store - SocketStore.addChangeListener(this.onSocketChange); + // Initalize websockets + Websockets.initialize(); // Get all statuses regularally. (Soon to be switched to websocket) this.intervalId = setInterval(() => AsyncClient.getStatuses(), CLIENT_STATUS_INTERVAL); @@ -89,7 +85,7 @@ export default class LoggedIn extends React.Component { // when one tab on a browser logs out, it sets __logout__ in localStorage to trigger other tabs to log out if (e.originalEvent.key === '__logout__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) - if (window.BrowserStore.isSignallingLogout(e.originalEvent.newValue)) { + if (BrowserStore.isSignallingLogout(e.originalEvent.newValue)) { return; } @@ -99,7 +95,7 @@ export default class LoggedIn extends React.Component { if (e.originalEvent.key === '__login__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) - if (window.BrowserStore.isSignallingLogin(e.originalEvent.newValue)) { + if (BrowserStore.isSignallingLogin(e.originalEvent.newValue)) { return; } @@ -178,7 +174,7 @@ export default class LoggedIn extends React.Component { $(window).off('focus'); $(window).off('blur'); - SocketStore.removeChangeListener(this.onSocketChange); + Websockets.close(); UserStore.removeChangeListener(this.onUserChanged); $('body').off('click.userpopover'); diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx index 57cac7229..d1446059d 100644 --- a/webapp/components/more_direct_channels.jsx +++ b/webapp/components/more_direct_channels.jsx @@ -5,9 +5,9 @@ import {Modal} from 'react-bootstrap'; import FilteredUserList from './filtered_user_list.jsx'; import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; -import loadingGif from 'images/load.gif'; import {FormattedMessage} from 'react-intl'; +import SpinnerButton from 'components/spinner_button.jsx'; import React from 'react'; @@ -83,26 +83,16 @@ export default class MoreDirectChannels extends React.Component { } createJoinDirectChannelButton({user}) { - if (this.state.loadingDMChannel === user.id) { - return ( - <img - className='channel-loading-gif' - src={loadingGif} - /> - ); - } - return ( - <button - type='button' - className='btn btn-primary btn-message' + <SpinnerButton + spinning={this.state.loadingDMChannel === user.id} onClick={this.handleShowDirectChannel.bind(this, user)} > <FormattedMessage id='more_direct_channels.message' defaultMessage='Message' /> - </button> + </SpinnerButton> ); } diff --git a/webapp/components/msg_typing.jsx b/webapp/components/msg_typing.jsx index b1781623c..b2d414287 100644 --- a/webapp/components/msg_typing.jsx +++ b/webapp/components/msg_typing.jsx @@ -1,21 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import SocketStore from 'stores/socket_store.jsx'; -import UserStore from 'stores/user_store.jsx'; +import UserTypingStore from 'stores/user_typing_store.jsx'; -import Constants from 'utils/constants.jsx'; - -import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; - -const SocketEvents = Constants.SocketEvents; - -const holders = defineMessages({ - someone: { - id: 'msg_typing.someone', - defaultMessage: 'Someone' - } -}); +import {FormattedMessage} from 'react-intl'; import React from 'react'; @@ -23,69 +11,40 @@ class MsgTyping extends React.Component { constructor(props) { super(props); - this.onChange = this.onChange.bind(this); + this.onTypingChange = this.onTypingChange.bind(this); this.updateTypingText = this.updateTypingText.bind(this); this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this); - this.typingUsers = {}; this.state = { text: '' }; } - componentDidMount() { - SocketStore.addChangeListener(this.onChange); + componentWillMount() { + UserTypingStore.addChangeListener(this.onTypingChange); + this.onTypingChange(); + } + + componentWillUnmount() { + UserTypingStore.removeChangeListener(this.onTypingChange); } componentWillReceiveProps(nextProps) { if (this.props.channelId !== nextProps.channelId) { - for (const u in this.typingUsers) { - if (!this.typingUsers.hasOwnProperty(u)) { - continue; - } - - clearTimeout(this.typingUsers[u]); - } - this.typingUsers = {}; - this.setState({text: ''}); + this.updateTypingText(UserTypingStore.getUsersTyping(nextProps.channelId, nextProps.parentId)); } } - componentWillUnmount() { - SocketStore.removeChangeListener(this.onChange); + onTypingChange() { + this.updateTypingText(UserTypingStore.getUsersTyping(this.props.channelId, this.props.parentId)); } - onChange(msg) { - let username = this.props.intl.formatMessage(holders.someone); - if (msg.action === SocketEvents.TYPING && - this.props.channelId === msg.channel_id && - this.props.parentId === msg.props.parent_id) { - if (UserStore.hasProfile(msg.user_id)) { - username = UserStore.getProfile(msg.user_id).username; - } - - 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) { - if (UserStore.hasProfile(msg.user_id)) { - username = UserStore.getProfile(msg.user_id).username; - } - clearTimeout(this.typingUsers[username]); - delete this.typingUsers[username]; - this.updateTypingText(); + updateTypingText(typingUsers) { + if (!typingUsers) { + return; } - } - updateTypingText() { - const users = Object.keys(this.typingUsers); + const users = Object.keys(typingUsers); let text = ''; switch (users.length) { case 0: @@ -129,9 +88,8 @@ class MsgTyping extends React.Component { } MsgTyping.propTypes = { - intl: intlShape.isRequired, channelId: React.PropTypes.string, parentId: React.PropTypes.string }; -export default injectIntl(MsgTyping);
\ No newline at end of file +export default MsgTyping; diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index fb3b25957..520f05ed0 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -8,6 +8,7 @@ import MessageWrapper from './message_wrapper.jsx'; import NotifyCounts from './notify_counts.jsx'; import ChannelInfoModal from './channel_info_modal.jsx'; import ChannelInviteModal from './channel_invite_modal.jsx'; +import ChannelMembersModal from './channel_members_modal.jsx'; import ChannelNotificationsModal from './channel_notifications_modal.jsx'; import DeleteChannelModal from './delete_channel_modal.jsx'; import RenameChannelModal from './rename_channel_modal.jsx'; @@ -433,6 +434,7 @@ export default class Navbar extends React.Component { var editChannelHeaderModal = null; var editChannelPurposeModal = null; let renameChannelModal = null; + let channelMembersModal = null; if (channel) { popoverContent = ( @@ -523,6 +525,14 @@ export default class Navbar extends React.Component { channel={channel} /> ); + + channelMembersModal = ( + <ChannelMembersModal + show={this.state.showMembersModal} + onModalDismissed={() => this.setState({showMembersModal: false})} + channel={channel} + /> + ); } var collapseButtons = this.createCollapseButtons(currentId); @@ -556,6 +566,7 @@ export default class Navbar extends React.Component { {editChannelHeaderModal} {editChannelPurposeModal} {renameChannelModal} + {channelMembersModal} </div> ); } diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index 7d048019c..819c7f590 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -1,6 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; + import UserStore from 'stores/user_store.jsx'; import {Popover, Overlay} from 'react-bootstrap'; import * as Utils from 'utils/utils.jsx'; @@ -20,6 +22,10 @@ export default class PopoverListMembers extends React.Component { this.closePopover = this.closePopover.bind(this); } + componentDidUpdate() { + $('.member-list__popover .popover-content').perfectScrollbar(); + } + componentWillMount() { this.setState({showPopover: false}); } diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index 29986d415..de99eb37d 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -5,7 +5,6 @@ import ReactDOM from 'react-dom'; import PostStore from 'stores/post_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserProfile from './user_profile.jsx'; -import UserStore from 'stores/user_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; @@ -97,8 +96,8 @@ class RhsComment extends React.Component { return ''; } - var isOwner = UserStore.getCurrentId() === post.user_id; - var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles); + const isOwner = this.props.currentUser.id === post.user_id; + const isAdmin = Utils.isAdmin(this.props.currentUser.roles); var dropdownContents = []; @@ -193,11 +192,11 @@ class RhsComment extends React.Component { var post = this.props.post; var currentUserCss = ''; - if (UserStore.getCurrentId() === post.user_id) { + if (this.props.currentUser === post.user_id) { currentUserCss = 'current--user'; } - var timestamp = UserStore.getCurrentUser().update_at; + var timestamp = this.props.currentUser.update_at; let loading; let postClass = ''; @@ -305,7 +304,8 @@ RhsComment.defaultProps = { RhsComment.propTypes = { intl: intlShape.isRequired, post: React.PropTypes.object, - user: React.PropTypes.object + user: React.PropTypes.object.isRequired, + currentUser: React.PropTypes.object.isRequired }; export default injectIntl(RhsComment); diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index a2c7ee7f8..1aa4a555f 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -55,8 +55,8 @@ export default class RhsRootPost extends React.Component { render() { const post = this.props.post; const user = this.props.user; - var isOwner = user.id === post.user_id; - var isAdmin = Utils.isAdmin(user.roles); + var isOwner = this.props.currentUser.id === post.user_id; + var isAdmin = Utils.isAdmin(this.props.currentUser.roles); var timestamp = UserStore.getProfile(post.user_id).update_at; var channel = ChannelStore.get(post.channel_id); @@ -286,5 +286,6 @@ RhsRootPost.defaultProps = { RhsRootPost.propTypes = { post: React.PropTypes.object.isRequired, user: React.PropTypes.object.isRequired, + currentUser: React.PropTypes.object.isRequired, commentCount: React.PropTypes.number }; diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread.jsx index cc900f8e7..f0324d7ce 100644 --- a/webapp/components/rhs_thread.jsx +++ b/webapp/components/rhs_thread.jsx @@ -46,6 +46,9 @@ export default class RhsThread extends React.Component { window.addEventListener('resize', this.handleResize); this.mounted = true; + if (!Utils.isMobile()) { + $('.sidebar--right .post-right__scroll').perfectScrollbar(); + } } componentDidUpdate() { if ($('.post-right__scroll')[0]) { @@ -130,7 +133,7 @@ export default class RhsThread extends React.Component { } // sort failed posts to bottom, followed by pending, and then regular posts - postsArray.sort(function postSort(a, b) { + postsArray.sort((a, b) => { if ((a.state === Constants.POST_LOADING || a.state === Constants.POST_FAILED) && (b.state !== Constants.POST_LOADING && b.state !== Constants.POST_FAILED)) { return 1; } @@ -182,24 +185,26 @@ export default class RhsThread extends React.Component { post={selected} commentCount={postsArray.length} user={profile} + currentUser={this.props.currentUser} /> <div className='post-right-comments-container'> - {postsArray.map(function mapPosts(comPost) { - let p; - if (UserStore.getCurrentId() === comPost.user_id) { - p = UserStore.getCurrentUser(); - } else { - p = profiles[comPost.user_id]; - } - return ( - <Comment - ref={comPost.id} - key={comPost.id + 'commentKey'} - post={comPost} - user={p} - /> - ); - })} + {postsArray.map((comPost) => { + let p; + if (UserStore.getCurrentId() === comPost.user_id) { + p = UserStore.getCurrentUser(); + } else { + p = profiles[comPost.user_id]; + } + return ( + <Comment + ref={comPost.id} + key={comPost.id + 'commentKey'} + post={comPost} + user={p} + currentUser={this.props.currentUser} + /> + ); + })} </div> <div className='post-create__container'> <CreateComment @@ -221,5 +226,6 @@ RhsThread.defaultProps = { RhsThread.propTypes = { fromSearch: React.PropTypes.string, - isMentionSearch: React.PropTypes.bool + isMentionSearch: React.PropTypes.bool, + currentUser: React.PropTypes.object.isRequired }; diff --git a/webapp/components/search_results.jsx b/webapp/components/search_results.jsx index 7619e41cd..c5baf50ef 100644 --- a/webapp/components/search_results.jsx +++ b/webapp/components/search_results.jsx @@ -61,6 +61,9 @@ export default class SearchResults extends React.Component { UserStore.addChangeListener(this.onUserChange); this.resize(); window.addEventListener('resize', this.handleResize); + if (!Utils.isMobile()) { + $('.sidebar--right .search-items-container').perfectScrollbar(); + } } shouldComponentUpdate(nextProps, nextState) { diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index 49ae1bec6..0e1b7dd0e 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -93,12 +93,12 @@ export default class Sidebar extends React.Component { const preferences = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); const directChannels = []; - for (const preference of preferences) { - if (preference.value !== 'true') { + for (const [name, value] of preferences) { + if (value !== 'true') { continue; } - const teammateId = preference.name; + const teammateId = name; let directChannel = channels.find(Utils.isDirectChannelForUser.bind(null, teammateId)); @@ -163,6 +163,9 @@ export default class Sidebar extends React.Component { componentDidUpdate() { this.updateTitle(); this.updateUnreadIndicators(); + if (!Utils.isMobile()) { + $('.sidebar--left .nav-pills__container').perfectScrollbar(); + } } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); @@ -239,11 +242,10 @@ export default class Sidebar extends React.Component { if (!this.isLeaving.get(channel.id)) { this.isLeaving.set(channel.id, true); - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, channel.teammate_id, 'false'); - - // bypass AsyncClient since we've already saved the updated preferences - Client.savePreferences( - [preference], + AsyncClient.savePreference( + Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + channel.teammate_id, + 'false', () => { this.isLeaving.set(channel.id, false); }, diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx index 1b3286963..a2e3914f3 100644 --- a/webapp/components/sidebar_right.jsx +++ b/webapp/components/sidebar_right.jsx @@ -8,6 +8,7 @@ import SearchResults from './search_results.jsx'; import RhsThread from './rhs_thread.jsx'; import SearchStore from 'stores/search_store.jsx'; import PostStore from 'stores/post_store.jsx'; +import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; const SIDEBAR_SCROLL_DELAY = 500; @@ -22,33 +23,38 @@ export default class SidebarRight extends React.Component { this.onSelectedChange = this.onSelectedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); + this.onUserChange = this.onUserChange.bind(this); this.onShowSearch = this.onShowSearch.bind(this); this.doStrangeThings = this.doStrangeThings.bind(this); - this.state = this.getStateFromStores(); - } - getStateFromStores() { - return { - search_visible: SearchStore.getSearchResults() != null, - post_right_visible: PostStore.getSelectedPost() != null, - is_mention_search: SearchStore.getIsMentionSearch() + this.state = { + searchVisible: !!SearchStore.getSearchResults(), + isMentionSearch: SearchStore.getIsMentionSearch(), + postRightVisible: !!PostStore.getSelectedPost(), + fromSearch: false, + currentUser: UserStore.getCurrentUser() }; } componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); SearchStore.addShowSearchListener(this.onShowSearch); + UserStore.addChangeListener(this.onUserChange); this.doStrangeThings(); } componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); SearchStore.removeShowSearchListener(this.onShowSearch); + UserStore.removeChangeListener(this.onUserChange); + } + shouldComponentUpdate(nextProps, nextState) { + return !Utils.areObjectsEqual(nextState, this.state); } componentWillUpdate(nextProps, nextState) { - const isOpen = this.state.search_visible || this.state.post_right_visible; - const willOpen = nextState.search_visible || nextState.post_right_visible; + const isOpen = this.state.searchVisible || this.state.postRightVisible; + const willOpen = nextState.searchVisible || nextState.postRightVisible; if (!isOpen && willOpen) { setTimeout(() => PostStore.jumpPostsViewSidebarOpen(), SIDEBAR_SCROLL_DELAY); @@ -66,7 +72,7 @@ export default class SidebarRight extends React.Component { $('.sidebar--right').addClass('move--left'); //$('.sidebar--right').prepend('<div class="sidebar__overlay"></div>'); - if (this.state.search_visible || this.state.post_right_visible) { + if (this.state.searchVisible || this.state.postRightVisible) { if (windowWidth > 960) { velocity($('.inner-wrap'), {marginRight: sidebarRightWidth}, {duration: 500, easing: 'easeOutSine'}); velocity($('.sidebar--right'), {translateX: 0}, {duration: 500, easing: 'easeOutSine'}); @@ -98,35 +104,40 @@ export default class SidebarRight extends React.Component { this.doStrangeThings(); } onSelectedChange(fromSearch) { - var newState = this.getStateFromStores(fromSearch); - newState.from_search = fromSearch; - if (!Utils.areObjectsEqual(newState, this.state)) { - this.setState(newState); - } + this.setState({ + postRightVisible: !!PostStore.getSelectedPost(), + fromSearch + }); } onSearchChange() { - var newState = this.getStateFromStores(); - if (!Utils.areObjectsEqual(newState, this.state)) { - this.setState(newState); - } + this.setState({ + searchVisible: !!SearchStore.getSearchResults(), + isMentionSearch: SearchStore.getIsMentionSearch() + }); + } + onUserChange() { + this.setState({ + currentUser: UserStore.getCurrentUser() + }); } onShowSearch() { - if (!this.state.search_visible) { + if (!this.state.searchVisible) { this.setState({ - search_visible: true + searchVisible: true }); } } render() { - var content = ''; + let content = null; - if (this.state.search_visible) { - content = <SearchResults isMentionSearch={this.state.is_mention_search}/>; - } else if (this.state.post_right_visible) { + if (this.state.searchVisible) { + content = <SearchResults isMentionSearch={this.state.isMentionSearch}/>; + } else if (this.state.postRightVisible) { content = ( <RhsThread - fromSearch={this.state.from_search} - isMentionSearch={this.state.is_mention_search} + fromSearch={this.state.fromSearch} + isMentionSearch={this.state.isMentionSearch} + currentUser={this.state.currentUser} /> ); } diff --git a/webapp/components/spinner_button.jsx b/webapp/components/spinner_button.jsx new file mode 100644 index 000000000..fcc9af8cd --- /dev/null +++ b/webapp/components/spinner_button.jsx @@ -0,0 +1,48 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import loadingGif from 'images/load.gif'; + +export default class SpinnerButton extends React.Component { + static get propTypes() { + return { + children: React.PropTypes.node, + spinning: React.PropTypes.bool.isRequired, + onClick: React.PropTypes.func + }; + } + + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + } + + handleClick(e) { + if (this.props.onClick) { + this.props.onClick(e); + } + } + + render() { + if (this.props.spinning) { + return ( + <img + className='spinner-button__gif' + src={loadingGif} + /> + ); + } + + return ( + <button + onClick={this.handleClick} + className='btn btn-sm btn-primary' + > + {this.props.children} + </button> + ); + } +} diff --git a/webapp/components/team_settings_modal.jsx b/webapp/components/team_settings_modal.jsx index 7dbbd680a..c19787993 100644 --- a/webapp/components/team_settings_modal.jsx +++ b/webapp/components/team_settings_modal.jsx @@ -5,6 +5,7 @@ import $ from 'jquery'; import ReactDOM from 'react-dom'; import SettingsSidebar from './settings_sidebar.jsx'; import TeamSettings from './team_settings.jsx'; +import * as Utils from 'utils/utils.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; @@ -49,9 +50,16 @@ class TeamSettingsModal extends React.Component { $('.modal-dialog.display--content').removeClass('display--content'); }, 500); }); + + if (!Utils.isMobile()) { + $('.settings-modal .settings-content').perfectScrollbar(); + } } updateTab(tab) { this.setState({activeTab: tab, activeSection: ''}); + if (!Utils.isMobile()) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); + } } updateSection(section) { this.setState({activeSection: section}); diff --git a/webapp/components/tutorial/tutorial_intro_screens.jsx b/webapp/components/tutorial/tutorial_intro_screens.jsx index 5db45523e..734842cad 100644 --- a/webapp/components/tutorial/tutorial_intro_screens.jsx +++ b/webapp/components/tutorial/tutorial_intro_screens.jsx @@ -36,17 +36,22 @@ export default class TutorialIntroScreens extends React.Component { Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL)); - let preference = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + let step = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 0); - const newValue = (parseInt(preference.value, 10) + 1).toString(); - - preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), newValue); - AsyncClient.savePreferences([preference]); + AsyncClient.savePreference( + Preferences.TUTORIAL_STEP, + UserStore.getCurrentId(), + step + 1 + ); } skipTutorial(e) { e.preventDefault(); - const preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), '999'); - AsyncClient.savePreferences([preference]); + + AsyncClient.savePreference( + Preferences.TUTORIAL_STEP, + UserStore.getCurrentId(), + 999 + ); } createScreen() { switch (this.state.currentScreen) { diff --git a/webapp/components/tutorial/tutorial_tip.jsx b/webapp/components/tutorial/tutorial_tip.jsx index ab49d4b04..d93fff1b1 100644 --- a/webapp/components/tutorial/tutorial_tip.jsx +++ b/webapp/components/tutorial/tutorial_tip.jsx @@ -29,12 +29,13 @@ export default class TutorialTip extends React.Component { this.setState({show}); if (!show && this.state.currentScreen >= this.props.screens.length - 1) { - let preference = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + let step = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 0); - const newValue = (parseInt(preference.value, 10) + 1).toString(); - - preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), newValue); - AsyncClient.savePreferences([preference]); + AsyncClient.savePreference( + Preferences.TUTORIAL_STEP, + UserStore.getCurrentId(), + step + 1 + ); } } handleNext() { diff --git a/webapp/components/user_list.jsx b/webapp/components/user_list.jsx index 2140158e6..3652723be 100644 --- a/webapp/components/user_list.jsx +++ b/webapp/components/user_list.jsx @@ -18,16 +18,22 @@ export default class UserList extends React.Component { key={user.id} user={user} actions={this.props.actions} + actionProps={this.props.actionProps} /> ); }); } else { content = ( - <div key='no-users-found'> - <FormattedMessage - id='user_list.notFound' - defaultMessage='No users found :(' - /> + <div + key='no-users-found' + className='no-channel-message' + > + <p className='primary-message'> + <FormattedMessage + id='user_list.notFound' + defaultMessage='No users found :(' + /> + </p> </div> ); } @@ -42,10 +48,12 @@ export default class UserList extends React.Component { UserList.defaultProps = { users: [], - actions: [] + actions: [], + actionProps: {} }; UserList.propTypes = { users: React.PropTypes.arrayOf(React.PropTypes.object), - actions: React.PropTypes.arrayOf(React.PropTypes.func) + actions: React.PropTypes.arrayOf(React.PropTypes.func), + actionProps: React.PropTypes.object }; diff --git a/webapp/components/user_list_row.jsx b/webapp/components/user_list_row.jsx index ed3a29a61..f6fd91688 100644 --- a/webapp/components/user_list_row.jsx +++ b/webapp/components/user_list_row.jsx @@ -6,7 +6,7 @@ import PreferenceStore from 'stores/preference_store.jsx'; import * as Utils from 'utils/utils.jsx'; import React from 'react'; -export default function UserListRow({user, actions}) { +export default function UserListRow({user, actions, actionProps}) { const nameFormat = PreferenceStore.get(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', ''); let name = user.username; @@ -21,6 +21,7 @@ export default function UserListRow({user, actions}) { <Action key={index.toString()} user={user} + {...actionProps} /> ); }); @@ -56,10 +57,12 @@ export default function UserListRow({user, actions}) { } UserListRow.defaultProps = { - actions: [] + actions: [], + actionProps: {} }; UserListRow.propTypes = { user: React.PropTypes.object.isRequired, - actions: React.PropTypes.arrayOf(React.PropTypes.func) + actions: React.PropTypes.arrayOf(React.PropTypes.func), + actionProps: React.PropTypes.object }; diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx index 7c496f57b..4fcdc9a41 100644 --- a/webapp/components/user_settings/user_settings_advanced.jsx +++ b/webapp/components/user_settings/user_settings_advanced.jsx @@ -1,11 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import * as Client from 'utils/client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import Constants from 'utils/constants.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; +import UserStore from 'stores/user_store.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; @@ -68,25 +69,27 @@ class AdvancedSettingsDisplay extends React.Component { const preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures); const advancedSettings = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS); const settings = { - send_on_ctrl_enter: PreferenceStore.getPreference( + send_on_ctrl_enter: PreferenceStore.get( Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', - {value: 'false'} - ).value + 'false' + ) }; let enabledFeatures = 0; - advancedSettings.forEach((setting) => { - preReleaseFeaturesKeys.forEach((key) => { + for (const [name, value] of advancedSettings) { + for (const key of preReleaseFeaturesKeys) { const feature = PreReleaseFeatures[key]; - if (setting.name === Constants.FeatureTogglePrefix + feature.label) { - settings[setting.name] = setting.value; - if (setting.value === 'true') { - enabledFeatures++; + + if (name === Constants.FeatureTogglePrefix + feature.label) { + settings[name] = value; + + if (value === 'true') { + enabledFeatures += 1; } } - }); - }); + } + } this.state = {preReleaseFeatures: PreReleaseFeatures, settings, preReleaseFeaturesKeys, enabledFeatures}; } @@ -124,20 +127,21 @@ class AdvancedSettingsDisplay extends React.Component { handleSubmit(settings) { const preferences = []; + const userId = UserStore.getCurrentId(); + // this should be refactored so we can actually be certain about what type everything is (Array.isArray(settings) ? settings : [settings]).forEach((setting) => { - preferences.push( - PreferenceStore.setPreference( - Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, - setting, - String(this.state.settings[setting]) - ) - ); + preferences.push({ + user_id: userId, + category: Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + name: setting, + value: this.state.settings[setting] + }); }); - Client.savePreferences(preferences, + AsyncClient.savePreferences( + preferences, () => { - PreferenceStore.emitChange(); this.updateSection(''); }, (err) => { diff --git a/webapp/components/user_settings/user_settings_display.jsx b/webapp/components/user_settings/user_settings_display.jsx index 3299588f7..e56156049 100644 --- a/webapp/components/user_settings/user_settings_display.jsx +++ b/webapp/components/user_settings/user_settings_display.jsx @@ -6,24 +6,22 @@ import SettingItemMax from '../setting_item_max.jsx'; import ManageLanguages from './manage_languages.jsx'; import ThemeSetting from './user_settings_theme.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; +import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; import * as I18n from 'i18n/i18n.jsx'; import Constants from 'utils/constants.jsx'; +const Preferences = Constants.Preferences; -import {savePreferences} from 'utils/client.jsx'; import {FormattedMessage} from 'react-intl'; function getDisplayStateFromStores() { - const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); - const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'username'}); - const selectedFont = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', {value: Constants.DEFAULT_FONT}); - return { - militaryTime: militaryTime.value, - nameFormat: nameFormat.value, - selectedFont: selectedFont.value + militaryTime: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', 'false'), + nameFormat: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'username'), + selectedFont: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', Constants.DEFAULT_FONT) }; } @@ -44,13 +42,29 @@ export default class UserSettingsDisplay extends React.Component { this.state = getDisplayStateFromStores(); } handleSubmit() { - const timePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); - const namePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', this.state.nameFormat); - const fontPreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', this.state.selectedFont); + const userId = UserStore.getCurrentId(); + + const timePreference = { + user_id: userId, + category: Preferences.CATEGORY_DISPLAY_SETTINGS, + name: 'use_military_time', + value: this.state.militaryTime + }; + const namePreference = { + user_id: userId, + category: Preferences.CATEGORY_DISPLAY_SETTINGS, + name: 'name_format', + value: this.state.nameFormat + }; + const fontPreference = { + user_id: userId, + category: Preferences.CATEGORY_DISPLAY_SETTINGS, + name: 'selected_font', + value: this.state.selectedFont + }; - savePreferences([timePreference, namePreference, fontPreference], + AsyncClient.savePreferences([timePreference, namePreference, fontPreference], () => { - PreferenceStore.emitChange(); this.updateSection(''); }, (err) => { diff --git a/webapp/components/user_settings/user_settings_modal.jsx b/webapp/components/user_settings/user_settings_modal.jsx index bd1df6ea5..d1c1f0fe2 100644 --- a/webapp/components/user_settings/user_settings_modal.jsx +++ b/webapp/components/user_settings/user_settings_modal.jsx @@ -64,7 +64,6 @@ class UserSettingsModal extends React.Component { constructor(props) { super(props); - this.handleShow = this.handleShow.bind(this); this.handleHide = this.handleHide.bind(this); this.handleHidden = this.handleHidden.bind(this); this.handleCollapse = this.handleCollapse.bind(this); @@ -95,24 +94,13 @@ class UserSettingsModal extends React.Component { } componentDidMount() { - if (this.props.show) { - this.handleShow(); - } UserStore.addChangeListener(this.onUserChanged); } - componentDidUpdate(prevProps) { - if (this.props.show && !prevProps.show) { - this.handleShow(); - } + componentDidUpdate() { UserStore.removeChangeListener(this.onUserChanged); - } - - handleShow() { - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } else { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 50); + if (!Utils.isMobile()) { + $('.settings-modal .modal-body').perfectScrollbar(); } } @@ -222,6 +210,10 @@ class UserSettingsModal extends React.Component { active_section: '' }); } + + if (!Utils.isMobile()) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); + } } updateSection(section, skipConfirm) { diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 9b44f9abd..d9411df07 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,12 +1,12 @@ { "about.close": "Close", "about.date": "Build Date:", - "about.enterpriseEditione1": "Enterprise Edition E1", + "about.enterpriseEditione1": "Enterprise Edition", "about.hash": "Build Hash:", "about.licensed": "Licensed by:", "about.number": "Build Number:", - "about.teamEditiont0": "Team Edition T0", - "about.teamEditiont1": "Team Edition T1", + "about.teamEditiont0": "Team Edition", + "about.teamEditiont1": "Enterprise Edition", "about.title": "About Mattermost", "about.version": "Version:", "access_history.title": "Access History", @@ -230,15 +230,9 @@ "admin.ldap.usernameAttrTitle": "Username Attribute:", "admin.licence.keyMigration": "If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start, <a href=\"http://mattermost.com\" target=\"_blank\">disable all Enterprise Edition features on this server</a>. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.", "admin.license.chooseFile": "Choose File", - "admin.license.edition": "Edition: ", - "admin.license.enterpriseEdition": "Mattermost Enterprise Edition. Designed for enterprise-scale communication.", - "admin.license.enterpriseType": "<div><p>This compiled release of Mattermost platform is provided under a <a href=\"http://mattermost.com\" target=\"_blank\">commercial license</a> from Mattermost, Inc. based on your subscription level and is subject to the <a href=\"{terms}\" target=\"_blank\">Terms of Service.</a></p><p>Your subscription details are as follows:</p>Name: {name}<br />Company or organization name: {company}<br/>Number of users: {users}<br/>License issued: {issued}<br/>Start date of license: {start}<br/>Expiry date of license: {expires}<br/>LDAP: {ldap}<br/></div>", - "admin.license.key": "License Key: ", "admin.license.keyRemove": "Remove Enterprise License and Downgrade Server", "admin.license.noFile": "No file uploaded", "admin.license.removing": "Removing License...", - "admin.license.teamEdition": "Mattermost Team Edition. Designed for teams from 5 to 50 users.", - "admin.license.teamType": "<span><p>This compiled release of Mattermost platform is offered under an MIT license.</p><p>See MIT-COMPILED-LICENSE.txt in your root install directory for details. See NOTICES.txt for information about open source software used in this system.</p></span>", "admin.license.title": "Edition and License", "admin.license.type": "License: ", "admin.license.upload": "Upload", @@ -416,6 +410,8 @@ "admin.support.emailTitle": "Support email:", "admin.support.helpDesc": "Link to help documentation from team site main menu. Typically not changed unless your organization chooses to create custom documentation.", "admin.support.helpTitle": "Help link:", + "admin.support.noteDescription": "If linking to an external site, URLs should begin with http:// or https://.", + "admin.support.noteTitle": "Note:", "admin.support.privacyDesc": "Link to Privacy Policy available to users on desktop and on mobile. Leaving this blank will hide the option to display a notice.", "admin.support.privacyTitle": "Privacy Policy link:", "admin.support.problemDesc": "Link to help documentation from team site main menu. By default this points to the peer-to-peer troubleshooting forum where users can search for, find and request help with technical issues.", @@ -425,8 +421,6 @@ "admin.support.termsDesc": "Link to Terms of Service available to users on desktop and on mobile. Leaving this blank will hide the option to display a notice.", "admin.support.termsTitle": "Terms of Service link:", "admin.support.title": "Legal and Support Settings", - "admin.support.noteTitle": "Note:", - "admin.support.noteDescription": "If linking to an external site, URLs should begin with http:// or https://.", "admin.system_analytics.activeUsers": "Active Users With Posts", "admin.system_analytics.title": "the System", "admin.system_analytics.totalPosts": "Total Posts", diff --git a/webapp/i18n/es.json b/webapp/i18n/es.json index 606b4376d..8852dd3c6 100644 --- a/webapp/i18n/es.json +++ b/webapp/i18n/es.json @@ -230,15 +230,9 @@ "admin.ldap.usernameAttrTitle": "Atributo Usuario:", "admin.licence.keyMigration": "Si estás migrando servidores es posible que necesites remover tu licencia de este servidor para poder instalarlo en un servidor nuevo. Para empezar, <a href=\"http://mattermost.com\" target=\"_blank\">deshabilita todas las características de la Edición Enterprise de este servidor</a>. Esta operación habilitará la opción para remover la licencia y degradar este servidor de la Edición Enterprise a la Edición Team.", "admin.license.chooseFile": "Escoger Archivo", - "admin.license.edition": "Edición: ", - "admin.license.enterpriseEdition": "Mattermost Edición Enterprise. Diseñada para comunicación de escala empresarial.", - "admin.license.enterpriseType": "<div><p>Esta versión compilada de la plataforma de Mattermost es provista bajo una <a href=\"http://mattermost.com\" target=\"_blank\">licencia comercial</a> de Mattermost, Inc. en función en su nivel de subscripción y bajo los <a href=\"{terms}\" target=\"_blank\">Términos del Servicio.</a></p><p>Los detalles de tu subscripción son los siguientes:</p>Nombre: {name}<br />Nombre compañía u organización: {company}<br/>Cantidad de usuarios: {users}<br/>Licencia emitida: {issued}<br/>Fecha de inicio: {start}<br/>Fecha de expiración: {expires}<br/>LDAP: {ldap}<br/></div>", - "admin.license.key": "Llave de la Licencia: ", "admin.license.keyRemove": "Remover la Licencia Enterprise y Degradar el Servidor", "admin.license.noFile": "No se subió ningún archivo", "admin.license.removing": "Removiendo Licencia...", - "admin.license.teamEdition": "Mattermost Edición Team. Diseñado para equipos desde 5 hasta 50 usuarios.", - "admin.license.teamType": "<span><p>Esta versión compilada de la plataforma de Mattermost es proporcionada bajo la licencia MIT.</p><p>Lea MIT-COMPILED-LICENSE.txt en el directorio raíz de la instalación para más detalles. Lea NOTICES.txt para información sobre software libre utilizado en este sistema.</p></span>", "admin.license.title": "Edición y Licencia", "admin.license.type": "Licencia: ", "admin.license.upload": "Subir", @@ -416,6 +410,8 @@ "admin.support.emailTitle": "Correo electrónico de Soporte:", "admin.support.helpDesc": "Enlace con la documentación de ayuda para el equipo desde el menú principal. Normalmente no cambia a menos que tu organización decida crear una documentación personalizada.", "admin.support.helpTitle": "Enlace de Ayuda:", + "admin.support.noteDescription": "Si se enlaza a un sitio externo, las URLs deben comenzar con http:// o https://.", + "admin.support.noteTitle": "Nota:", "admin.support.privacyDesc": "Enlace para las políticas de Privacidad disponible para los usuarios en versión de escritorio y movil. Al dejarlo en blanco esconderá la opción que muestra el aviso.", "admin.support.privacyTitle": "Enlace de políticas de Privacidad:", "admin.support.problemDesc": "Enlace con la documentación de ayuda para el equipo desde el menú principal. Como predeterminado esto apunta a un foro de ayuda donde los usuarios pueden buscar, encontrar y solicitar ayuda sobre temas técnicos.", diff --git a/webapp/i18n/pt.json b/webapp/i18n/pt.json index 17ffe1b16..41d3bbc1c 100644 --- a/webapp/i18n/pt.json +++ b/webapp/i18n/pt.json @@ -1,12 +1,12 @@ { "about.close": "Fechar", "about.date": "Data De Criação:", - "about.enterpriseEditione1": "Enterprise Edition E1", + "about.enterpriseEditione1": "Enterprise Edition", "about.hash": "Hash de Compilação:", "about.licensed": "Licenciado pela:", "about.number": "O Número De Compilação:", - "about.teamEditiont0": "Team Edition T0", - "about.teamEditiont1": "Team Edition T1", + "about.teamEditiont0": "Team Edition", + "about.teamEditiont1": "Enterprise Edition", "about.title": "Sobre o Mattermost", "about.version": "Versão:", "access_history.title": "Histórico de Acesso", @@ -24,6 +24,39 @@ "activity_log_modal.iphoneNativeApp": "App Nativo para iPhone", "admin.audits.reload": "Recarregar", "admin.audits.title": "Atividade de Usuário", + "admin.compliance.directoryDescription": "Diretório o qual os relatórios compliance são gravados, Se estiver em branco, será usado ./data/.", + "admin.compliance.directoryExample": "Ex \"./data/\"", + "admin.compliance.directoryTitle": "Localização do Diretório de Compliance:", + "admin.compliance.enableDailyTitle": "Ativar Relatório Diário:", + "admin.compliance.enableDesc": "Quando verdadeiro, Mattermost irá gerar um relatório diário de compliance.", + "admin.compliance.enableTitle": "Ativar Compliance:", + "admin.compliance.false": "falso", + "admin.compliance.noLicense": "<h4 class=\"banner__heading\">Nota:</h4><p>Compliance é um recurso empresarial. Sua licença atual não suporta Compliance. Clique <a href=\"http://mattermost.com\" target=\"_blank\">aqui</a> para informações e preços da licença empresarial.</p>", + "admin.compliance.save": "Salvar", + "admin.compliance.saving": "Salvando Config...", + "admin.compliance.title": "Configurações Compliance", + "admin.compliance.true": "verdadeiro", + "admin.compliance_reports.desc": "Nome da Tarefa:", + "admin.compliance_reports.desc_placeholder": "Ex \"Audit 445 for HR\"", + "admin.compliance_reports.emails": "Emails:", + "admin.compliance_reports.emails_placeholder": "Ex \"bill@example.com, bob@example.com\"", + "admin.compliance_reports.from": "De:", + "admin.compliance_reports.from_placeholder": "Ex \"2016-03-11\"", + "admin.compliance_reports.keywords": "Palavras-chave:", + "admin.compliance_reports.keywords_placeholder": "Ex \"diminuir estoque\"", + "admin.compliance_reports.reload": "Recarregar", + "admin.compliance_reports.run": "Executar", + "admin.compliance_reports.title": "Relatórios Compliance", + "admin.compliance_reports.to": "Para:", + "admin.compliance_reports.to_placeholder": "Ex \"2016-03-15\"", + "admin.compliance_table.desc": "Descrição", + "admin.compliance_table.download": "Download", + "admin.compliance_table.params": "Parâmetros", + "admin.compliance_table.records": "Registros", + "admin.compliance_table.status": "Status", + "admin.compliance_table.timestamp": "Timestamp", + "admin.compliance_table.type": "Tipo", + "admin.compliance_table.userId": "Solicitado Por", "admin.email.allowEmailSignInDescription": "Quando verdadeiro, Mattermost permite aos usuários fazer login usando o e-mail e senha.", "admin.email.allowEmailSignInTitle": "Permitir Login Com E-mail: ", "admin.email.allowSignupDescription": "Quando verdadeiro, Mattermost permite a criação de equipe e conta de inscrição através de e-mail e senha. Este valor deve ser falso somente quando você deseja limitar a entrada para o single-sign-on service como OAuth ou LDAP.", @@ -42,6 +75,8 @@ "admin.email.emailSettings": "Configuração do e-mail", "admin.email.emailSuccess": "Nenhum erro foram relatados durante o envio de um e-mail. Por favor verifique a sua caixa de entrada para se certificar.", "admin.email.false": "falso", + "admin.email.fullPushNotification": "Enviar trecho de mensagem", + "admin.email.genericPushNotification": "Enviar descrição genérica com nomes do usuário e canal", "admin.email.inviteSaltDescription": "32-caracteres salt adicionados a assinatura de convites por e-mail. Aleatoriamente gerados na instalação. Click \"Re-Gerar\" para criar um novo salt.", "admin.email.inviteSaltExample": "Ex \"bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo\"", "admin.email.inviteSaltTitle": "Salt Convite:", @@ -56,6 +91,8 @@ "admin.email.passwordSaltDescription": "32-caracteres de salt adicionado para assinar o redefinição de senha de e-mails. Gerada aleatoriamente na instalação. Clique em \"Re-Gerar\" para criar novos salt.", "admin.email.passwordSaltExample": "Ex \"bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo\"", "admin.email.passwordSaltTitle": "Salt Reset Senha:", + "admin.email.pushContentDesc": "Selecionar \"Enviar descrição genérica com nomes de usuário e canal\" fornece envio de notificações com mensagens genéricas, inclui nomes de usuários e canais mas não específica detalhes do texto da mensagem.<br /><br />Selecionar \"Enviar trecho da mensagem\" envia trechos da mensagem que desencadearam as notificações e pode incluir informações confidenciais enviadas na mensagem. Se o seu serviço de Envio de Notificação está fora de um firewall, é ALTAMENTE RECOMENDADO que está opção somente seja usada com o protocolo \"https\" para encriptar a conexão.", + "admin.email.pushContentTitle": "Enviar Notificação Contendo:", "admin.email.pushDesc": "Normalmente definida como verdadeiro na produção. Quando verdadeiro, Mattermost tenta enviar notificações no iOS e Android, através do servidor de notificação.", "admin.email.pushServerDesc": "Localização do serviço de notificação push Mattermost você pode configurar por trás do firewall usando https://github.com/mattermost/push-proxy. Para testar, você pode usar http://push-test.mattermost.com, que liga à amostra do app Mattermost iOS na Apple AppStore pública. Por favor, não use o serviço de teste para implantações de produção.", "admin.email.pushServerEx": "Ex: \"http://push-test.mattermost.com\"", @@ -326,6 +363,7 @@ "admin.service.webhooksTitle": "Ativar Webhooks Entrada: ", "admin.sidebar.addTeamSidebar": "Adicionar equipe do menu lateral", "admin.sidebar.audits": "Conformidade e Auditoria", + "admin.sidebar.compliance": "Configurações Compliance", "admin.sidebar.email": "Configuração do e-mail", "admin.sidebar.file": "Configurações do arquivo", "admin.sidebar.gitlab": "Configurações GitLab", @@ -387,6 +425,8 @@ "admin.support.termsDesc": "Link para os Termos de Serviço para os usuários no desktop ou móvel. Deixando este espaço em branco irá esconder a opção de exibir um aviso.", "admin.support.termsTitle": "Link Termos do Serviço:", "admin.support.title": "Configurações jurídico e apoio", + "admin.support.noteTitle": "Note:", + "admin.support.noteDescription": "If linking to an external site, URLs should begin with http:// or https://.", "admin.system_analytics.activeUsers": "Usuários Ativos com Postagens", "admin.system_analytics.title": "o Sistema", "admin.system_analytics.totalPosts": "Total Posts", @@ -660,13 +700,13 @@ "email_signup.emailError": "Por favor introduza um endereço de e-mail válido", "email_signup.find": "Encontrar minhas equipes", "email_verify.almost": "{siteName}: Você está quase pronto", + "email_verify.failed": " Falha ao enviar verificação por email.", "email_verify.notVerifiedBody": "Por favor verifique seu endereço de email. Verifique por um email em sua caixa de entrada.", - "email_verify.verifyFailed": "Falha ao verificar seu email.", "email_verify.resend": "Re-enviar Email", "email_verify.sent": " Verificação de email enviado.", - "email_verify.failed": " Falha ao enviar verificação por email.", "email_verify.verified": "{siteName} Email Verificado", "email_verify.verifiedBody": "<p>Seu email foi verificado! Clique <a href={url}>aqui</a> para login.</p>", + "email_verify.verifyFailed": "Falha ao verificar seu email.", "error_bar.preview_mode": "Modo de visualização: Notificações por E-mail não foram configuradas", "file_attachment.download": "Download", "file_info_preview.size": "Tamanho ", @@ -758,9 +798,9 @@ "login.noAccount": "Não tem uma conta? ", "login.on": "no {siteName}", "login.or": "ou", + "login.session_expired": " Sua sessão expirou. Por favor faça login novamente.", "login.signTo": "Login em:", "login.verified": " Email Verificado", - "login.session_expired": " Sua sessão expirou. Por favor faça login novamente.", "login_email.badTeam": "Nome ruim de equipe", "login_email.email": "E-mail", "login_email.emailReq": "Um email é necessário", @@ -1102,6 +1142,7 @@ "user.settings.advance.preReleaseTitle": "Visualizar recursos de pré-lançamento", "user.settings.advance.sendDesc": "Se habilitado 'Enter' insere uma nova linha e 'Ctrl + Enter' envia a mensagem.", "user.settings.advance.sendTitle": "Enviar mensagens Ctrl + Enter", + "user.settings.advance.slashCmd_autocmp": "Ativar aplicação externa para autocompletar comandos slash", "user.settings.advance.title": "Configurações Avançadas", "user.settings.cmds.add": "Adicionar", "user.settings.cmds.add_desc": "Criar comandos slash para enviar eventos para integrações externas e receber uma resposta. Por exemplo digitando `/patient Joe Smith` poderia trazer de volta os resultados de pesquisa a partir do seu sistema de gestão de registos internos de saúde para o nome “Joe Smith”. Por favor veja <a href=\"http://docs.mattermost.com/developer/slash-commands.html\">Documentação comandos Slash</a> para detalhes e instruções. Ver todos os comandos slash configurados nesta equipe abaixo.", @@ -1130,6 +1171,7 @@ "user.settings.cmds.request_type_desc": "O tipo de solicitação do comando emitido para a URL requisitada.", "user.settings.cmds.request_type_get": "GET", "user.settings.cmds.request_type_post": "POST", + "user.settings.cmds.slashCmd_autocmp": "Ativar aplicação externa para autocompletar", "user.settings.cmds.token": "Token: ", "user.settings.cmds.trigger": "Comando Palavra Gatilho: ", "user.settings.cmds.trigger_desc": "Exemplos: /patient, /client, /employee Reserved: /echo, /join, /logout, /me, /shrug", @@ -1313,4 +1355,4 @@ "web.footer.terms": "Termos", "web.header.back": "Voltar", "web.root.singup_info": "Toda comunicação em um só lugar, pesquisável e acessível em qualquer lugar" -} +}
\ No newline at end of file diff --git a/webapp/package.json b/webapp/package.json index 464083bd4..6f50962a4 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -11,6 +11,7 @@ "fastclick": "1.0.6", "flux": "2.1.1", "highlight.js": "9.2.0", + "intl": "1.1.0", "jasny-bootstrap": "3.1.3", "jquery": "2.2.1", "keymirror": "0.1.1", @@ -53,8 +54,8 @@ "webpack": "webpack/webpack#master" }, "scripts": { - "check": "eslint --ext \".jsx\" --ignore-pattern node_modules --quiet .", - "build": "webpack", - "run": "webpack --progress --watch" + "check": "eslint --ext \".jsx\" --ignore-pattern node_modules --quiet .", + "build": "webpack", + "run": "webpack --progress --watch" } } diff --git a/webapp/root.jsx b/webapp/root.jsx index 2ce220f1d..63fbb4422 100644 --- a/webapp/root.jsx +++ b/webapp/root.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import $ from 'jquery'; +require('perfect-scrollbar/jquery')($); import 'bootstrap/dist/css/bootstrap.css'; import 'jasny-bootstrap/dist/css/jasny-bootstrap.css'; @@ -24,11 +25,11 @@ import Sidebar from 'components/sidebar.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import SocketStore from 'stores/socket_store.jsx'; import ErrorStore from 'stores/error_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import SignupTeam from 'components/signup_team.jsx'; import * as Client from 'utils/client.jsx'; +import * as Websockets from 'action_creators/websocket_actions.jsx'; import * as GlobalActions from 'action_creators/global_actions.jsx'; import SignupTeamConfirm from 'components/signup_team_confirm.jsx'; import SignupUserComplete from 'components/signup_user_complete.jsx'; @@ -101,29 +102,43 @@ function preRenderSetup(callwhendone) { // Do Nothing }; + // Make sure the websockets close $(window).on('beforeunload', () => { - if (window.SocketStore) { - SocketStore.close(); - } + Websockets.close(); } ); - addLocaleData(enLocaleData); - addLocaleData(esLocaleData); - addLocaleData(ptLocaleData); + function afterIntl() { + addLocaleData(enLocaleData); + addLocaleData(esLocaleData); + addLocaleData(ptLocaleData); - $.when(d1, d2).done(callwhendone); + $.when(d1, d2).done(callwhendone); + } + + if (global.Intl) { + afterIntl(); + } else { + require.ensure([ + 'intl', + 'intl/locale-data/jsonp/en.js', + 'intl/locale-data/jsonp/es.js', + 'intl/locale-data/jsonp/pt.js' + ], (require) => { + require('intl'); + require('intl/locale-data/jsonp/en.js'); + require('intl/locale-data/jsonp/es.js'); + require('intl/locale-data/jsonp/pt.js'); + afterIntl(); + }); + } } function preLoggedIn(nextState, replace, callback) { const d1 = Client.getAllPreferences( (data) => { - if (!data) { - return; - } - - PreferenceStore.setPreferences(data); + PreferenceStore.setPreferencesFromServer(data); }, (err) => { AsyncClient.dispatchError(err, 'getAllPreferences'); @@ -179,6 +194,7 @@ function onLoggedOut(nextState) { BrowserStore.signalLogout(); BrowserStore.clear(); ErrorStore.clearLastError(); + PreferenceStore.clear(); }, () => { browserHistory.push('/' + teamName + '/login'); diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss index 94378aabe..4e2049857 100644 --- a/webapp/sass/components/_modal.scss +++ b/webapp/sass/components/_modal.scss @@ -9,6 +9,7 @@ } .modal-body { + max-height: calc(90vh - 62px); overflow: auto; padding: 20px 15px; diff --git a/webapp/sass/components/_spinner-button.scss b/webapp/sass/components/_spinner-button.scss new file mode 100644 index 000000000..e683ad4f4 --- /dev/null +++ b/webapp/sass/components/_spinner-button.scss @@ -0,0 +1,8 @@ +@charset 'UTF-8'; + +.spinner-button__gif { + height: 15px; + margin-top: 2px; + width: 15px; +} + diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss index 9b631d00f..e75d2556b 100644 --- a/webapp/sass/layout/_headers.scss +++ b/webapp/sass/layout/_headers.scss @@ -134,6 +134,10 @@ @include alpha-property(background, $black, .1); } + a { + text-decoration: none; + } + .user__name { color: $white; } diff --git a/webapp/sass/layout/_navigation.scss b/webapp/sass/layout/_navigation.scss index 87e4b4d27..3daf6e56b 100644 --- a/webapp/sass/layout/_navigation.scss +++ b/webapp/sass/layout/_navigation.scss @@ -89,7 +89,7 @@ &.info-popover { @include background-size(100% 100%); - background: url('../images/info__icon.png'); + background-image: url('../images/info__icon.png'); cursor: pointer; height: 19px; position: relative; diff --git a/webapp/sass/layout/_sidebar-left.scss b/webapp/sass/layout/_sidebar-left.scss index 4c65d0a65..3a5e74570 100644 --- a/webapp/sass/layout/_sidebar-left.scss +++ b/webapp/sass/layout/_sidebar-left.scss @@ -1,7 +1,6 @@ @charset 'UTF-8'; .sidebar--left { - background: #fafafa; border-right: $border-gray; height: 100%; left: 0; diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 0b47e5ab6..7e53713c0 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -10,12 +10,8 @@ width: 100%; } - .row { - margin: 0; - } - h3 { - border-bottom: 1px solid #ddd; + border-bottom: 1px solid alpha-color($black, .1); font-weight: 600; margin: 1em 0; padding-bottom: .5em; @@ -75,11 +71,18 @@ width: 17px; } + &.divider { + @include alpha-property(background, $black, .1); + } + > a { &:hover, - &.active, &:focus { - background-color: #eaeaea; + @include alpha-property(background, $black, .1); + } + + &.active { + background-color: transparent; } } diff --git a/webapp/stores/browser_store.jsx b/webapp/stores/browser_store.jsx index 66190f6a2..bba146e38 100644 --- a/webapp/stores/browser_store.jsx +++ b/webapp/stores/browser_store.jsx @@ -8,6 +8,8 @@ function getPrefix() { return global.window.mm_current_user_id + '_'; } + console.log('BrowserStore tried to operate without user present'); //eslint-disable-line no-console + return 'unknown_'; } @@ -34,46 +36,35 @@ class BrowserStoreClass { } checkVersion() { - var currentVersion = sessionStorage.getItem('storage_version'); + var currentVersion = this.getGlobalItem('storage_version'); if (currentVersion !== global.window.mm_config.Version) { - sessionStorage.clear(); + this.clearAll(); try { - sessionStorage.setItem('storage_version', global.window.mm_config.Version); + this.setGlobalItem('storage_version', global.window.mm_config.Version); } catch (e) { // Do nothing } } } - getItem(name, defaultValue) { - var result = null; - try { - result = JSON.parse(sessionStorage.getItem(getPrefix() + name)); - } catch (err) { - result = null; - } - - if (result === null && typeof defaultValue !== 'undefined') { - result = defaultValue; - } - - return result; + setItem(name, value) { + this.setGlobalItem(getPrefix() + name, value); } - setItem(name, value) { - sessionStorage.setItem(getPrefix() + name, JSON.stringify(value)); + getItem(name, defaultValue) { + return this.getGlobalItem(getPrefix() + name, defaultValue); } removeItem(name) { - sessionStorage.removeItem(getPrefix() + name); + this.removeGlobalItem(getPrefix() + name); } setGlobalItem(name, value) { try { if (this.isLocalStorageSupported()) { - localStorage.setItem(getPrefix() + name, JSON.stringify(value)); + localStorage.setItem(name, JSON.stringify(value)); } else { - sessionStorage.setItem(getPrefix() + name, JSON.stringify(value)); + sessionStorage.setItem(name, JSON.stringify(value)); } } catch (err) { console.log('An error occurred while setting local storage, clearing all props'); //eslint-disable-line no-console @@ -87,9 +78,9 @@ class BrowserStoreClass { var result = null; try { if (this.isLocalStorageSupported()) { - result = JSON.parse(localStorage.getItem(getPrefix() + name)); + result = JSON.parse(localStorage.getItem(name)); } else { - result = JSON.parse(sessionStorage.getItem(getPrefix() + name)); + result = JSON.parse(sessionStorage.getItem(name)); } } catch (err) { result = null; @@ -104,18 +95,18 @@ class BrowserStoreClass { removeGlobalItem(name) { if (this.isLocalStorageSupported()) { - localStorage.removeItem(getPrefix() + name); + localStorage.removeItem(name); } else { - sessionStorage.removeItem(getPrefix() + name); + sessionStorage.removeItem(name); } } getLastServerVersion() { - return sessionStorage.getItem('last_server_version'); + return this.getGlobalItem('last_server_version'); } setLastServerVersion(version) { - sessionStorage.setItem('last_server_version', version); + this.setGlobalItem('last_server_version', version); } signalLogout() { @@ -185,6 +176,7 @@ class BrowserStoreClass { const logoutId = sessionStorage.getItem('__logout__'); sessionStorage.clear(); + localStorage.clear(); if (logoutId) { sessionStorage.setItem('__logout__', logoutId); @@ -222,4 +214,3 @@ class BrowserStoreClass { var BrowserStore = new BrowserStoreClass(); export default BrowserStore; -window.BrowserStore = BrowserStore; diff --git a/webapp/stores/error_store.jsx b/webapp/stores/error_store.jsx index 776375a82..7c695a335 100644 --- a/webapp/stores/error_store.jsx +++ b/webapp/stores/error_store.jsx @@ -35,15 +35,15 @@ class ErrorStoreClass extends EventEmitter { } getLastError() { - return BrowserStore.getItem('last_error'); + return BrowserStore.getGlobalItem('last_error'); } storeLastError(error) { - BrowserStore.setItem('last_error', error); + BrowserStore.setGlobalItem('last_error', error); } getConnectionErrorCount() { - var count = BrowserStore.getItem('last_error_conn'); + var count = BrowserStore.getGlobalItem('last_error_conn'); if (count == null) { return 0; @@ -53,12 +53,12 @@ class ErrorStoreClass extends EventEmitter { } setConnectionErrorCount(count) { - BrowserStore.setItem('last_error_conn', count); + BrowserStore.setGlobalItem('last_error_conn', count); } clearLastError() { - BrowserStore.removeItem('last_error'); - BrowserStore.removeItem('last_error_conn'); + BrowserStore.removeGlobalItem('last_error'); + BrowserStore.removeGlobalItem('last_error_conn'); } } diff --git a/webapp/stores/notificaiton_store.jsx b/webapp/stores/notificaiton_store.jsx new file mode 100644 index 000000000..70caffeb6 --- /dev/null +++ b/webapp/stores/notificaiton_store.jsx @@ -0,0 +1,98 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; +import Constants from 'utils/constants.jsx'; +import UserStore from './user_store.jsx'; +import ChannelStore from './channel_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +const ActionTypes = Constants.ActionTypes; + +const CHANGE_EVENT = 'change'; + +class NotificationStoreClass extends EventEmitter { + emitChange() { + this.emit(CHANGE_EVENT); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + + handleRecievedPost(post, msgProps) { + // Send desktop notification + if ((UserStore.getCurrentId() !== post.user_id || post.props.from_webhook === 'true') && !Utils.isSystemMessage(post)) { + let mentions = []; + if (msgProps.mentions) { + mentions = JSON.parse(msgProps.mentions); + } + + const channel = ChannelStore.get(post.channel_id); + const user = UserStore.getCurrentUser(); + const member = ChannelStore.getMember(post.channel_id); + + let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; + if (notifyLevel === 'default') { + notifyLevel = user.notify_props.desktop; + } + + if (notifyLevel === 'none') { + return; + } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== Constants.DM_CHANNEL) { + return; + } + + let username = Utils.localizeMessage('channel_loader.someone', 'Someone'); + if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { + username = post.props.override_username; + } else if (UserStore.hasProfile(post.user_id)) { + username = UserStore.getProfile(post.user_id).username; + } + + let title = Utils.localizeMessage('channel_loader.posted', 'Posted'); + if (channel) { + title = channel.display_name; + } + + let notifyText = post.message.replace(/\n+/g, ' '); + if (notifyText.length > 50) { + notifyText = notifyText.substring(0, 49) + '...'; + } + + if (notifyText.length === 0) { + if (msgProps.image) { + Utils.notifyMe(title, username + Utils.localizeMessage('channel_loader.uploadedImage', ' uploaded an image'), channel); + } else if (msgProps.otherFile) { + Utils.notifyMe(title, username + Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file'), channel); + } else { + Utils.notifyMe(title, username + Utils.localizeMessage('channel_loader.something', ' did something new'), channel); + } + } else { + Utils.notifyMe(title, username + Utils.localizeMessage('channel_loader.wrote', ' wrote: ') + notifyText, channel); + } + if (!user.notify_props || user.notify_props.desktop_sound === 'true') { + Utils.ding(); + } + } + } +} + +var NotificationStore = new NotificationStoreClass(); + +NotificationStore.dispatchToken = AppDispatcher.register((payload) => { + const action = payload.action; + + switch (action.type) { + case ActionTypes.RECEIVED_POST: + NotificationStore.handleRecievedPost(action.post, action.webspcketMessageProps); + NotificationStore.emitChange(); + break; + } +}); + +export default NotificationStore; diff --git a/webapp/stores/preference_store.jsx b/webapp/stores/preference_store.jsx index df77f0d51..fcfd1c426 100644 --- a/webapp/stores/preference_store.jsx +++ b/webapp/stores/preference_store.jsx @@ -4,143 +4,80 @@ import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import BrowserStore from './browser_store.jsx'; import EventEmitter from 'events'; -import UserStore from 'stores/user_store.jsx'; const CHANGE_EVENT = 'change'; -function getPreferenceKey(category, name) { - return `${category}-${name}`; -} - -function getPreferenceKeyForModel(preference) { - return `${preference.category}-${preference.name}`; -} - class PreferenceStoreClass extends EventEmitter { constructor() { super(); - this.getAllPreferences = this.getAllPreferences.bind(this); - this.get = this.get.bind(this); - this.getBool = this.getBool.bind(this); - this.getInt = this.getInt.bind(this); - this.getPreference = this.getPreference.bind(this); - this.getCategory = this.getCategory.bind(this); - this.getPreferencesWhere = this.getPreferencesWhere.bind(this); - this.setAllPreferences = this.setAllPreferences.bind(this); - this.setPreference = this.setPreference.bind(this); - - this.emitChange = this.emitChange.bind(this); - this.addChangeListener = this.addChangeListener.bind(this); - this.removeChangeListener = this.removeChangeListener.bind(this); - this.handleEventPayload = this.handleEventPayload.bind(this); this.dispatchToken = AppDispatcher.register(this.handleEventPayload); + + this.preferences = new Map(); } - getAllPreferences() { - return new Map(BrowserStore.getItem('preferences', [])); + getKey(category, name) { + return `${category}--${name}`; } get(category, name, defaultValue = '') { - const preference = this.getAllPreferences().get(getPreferenceKey(category, name)); + const key = this.getKey(category, name); - if (!preference) { + if (!this.preferences.has(key)) { return defaultValue; } - return preference.value || defaultValue; + return this.preferences.get(key); } getBool(category, name, defaultValue = false) { - const preference = this.getAllPreferences().get(getPreferenceKey(category, name)); + const key = this.getKey(category, name); - if (!preference) { + if (!this.preferences.has(key)) { return defaultValue; } - // prevent a non-false default value from being returned instead of an actual false value - if (preference.value === 'false') { - return false; - } - - return (preference.value !== 'false') || defaultValue; + return this.preferences.get(key) !== 'false'; } getInt(category, name, defaultValue = 0) { - const preference = this.getAllPreferences().get(getPreferenceKey(category, name)); + const key = this.getKey(category, name); - if (!preference) { + if (!this.preferences.has(key)) { return defaultValue; } - // prevent a non-zero default value from being returned instead of an actual 0 value - if (preference.value === '0') { - return 0; - } - - return parseInt(preference.value, 10) || defaultValue; - } - - getPreference(category, name, defaultValue = {}) { - return this.getAllPreferences().get(getPreferenceKey(category, name)) || defaultValue; + return parseInt(this.preferences.get(key), 10); } getCategory(category) { - return this.getPreferencesWhere((preference) => (preference.category === category)); - } + const prefix = category + '--'; - getPreferencesWhere(pred) { - const all = this.getAllPreferences(); - const preferences = []; + const preferences = new Map(); - for (const [, preference] of all) { - if (pred(preference)) { - preferences.push(preference); + for (const [key, value] of this.preferences) { + if (key.startsWith(prefix)) { + preferences.set(key.substring(prefix.length), value); } } return preferences; } - setAllPreferences(preferences) { - // note that we store the preferences as an array of key-value pairs so that we can deserialize - // it as a proper Map instead of an object - BrowserStore.setItem('preferences', [...preferences]); - } - setPreference(category, name, value) { - const preferences = this.getAllPreferences(); - - const key = getPreferenceKey(category, name); - let preference = preferences.get(key); - - if (!preference) { - preference = { - user_id: UserStore.getCurrentId(), - category, - name - }; - } - preference.value = value; - - preferences.set(key, preference); - - this.setAllPreferences(preferences); - - return preference; + this.preferences.set(this.getKey(category, name), value); } - setPreferences(newPreferences) { - const preferences = this.getAllPreferences(); - + setPreferencesFromServer(newPreferences) { for (const preference of newPreferences) { - preferences.set(getPreferenceKeyForModel(preference), preference); + this.setPreference(preference.category, preference.name, preference.value); } + } - this.setAllPreferences(preferences); + clear() { + this.preferences.clear(); } emitChange() { @@ -166,7 +103,7 @@ class PreferenceStoreClass extends EventEmitter { break; } case ActionTypes.RECEIVED_PREFERENCES: - this.setPreferences(action.preferences); + this.setPreferencesFromServer(action.preferences); this.emitChange(); break; } diff --git a/webapp/stores/socket_store.jsx b/webapp/stores/socket_store.jsx deleted file mode 100644 index 5d6302743..000000000 --- a/webapp/stores/socket_store.jsx +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import $ from 'jquery'; -import UserStore from './user_store.jsx'; -import PostStore from './post_store.jsx'; -import ChannelStore from './channel_store.jsx'; -import BrowserStore from './browser_store.jsx'; -import ErrorStore from './error_store.jsx'; -import EventEmitter from 'events'; - -import * as Utils from 'utils/utils.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import * as GlobalActions from 'action_creators/global_actions.jsx'; - -import Constants from 'utils/constants.jsx'; -const SocketEvents = Constants.SocketEvents; - -const CHANGE_EVENT = 'change'; - -var conn; - -class SocketStoreClass extends EventEmitter { - constructor() { - super(); - - this.initialize = this.initialize.bind(this); - this.emitChange = this.emitChange.bind(this); - this.addChangeListener = this.addChangeListener.bind(this); - this.removeChangeListener = this.removeChangeListener.bind(this); - this.sendMessage = this.sendMessage.bind(this); - this.close = this.close.bind(this); - - this.failCount = 0; - this.isInitialize = false; - - this.translations = this.getDefaultTranslations(); - - this.initialize(); - } - - initialize() { - if (!UserStore.getCurrentId()) { - return; - } - - this.setMaxListeners(0); - - if (window.WebSocket && !conn) { - var protocol = 'ws://'; - if (window.location.protocol === 'https:') { - protocol = 'wss://'; - } - - var connUrl = protocol + location.host + ((/:\d+/).test(location.host) ? '' : Utils.getWebsocketPort(protocol)) + '/api/v1/websocket'; - - if (this.failCount === 0) { - console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console - } - - conn = new WebSocket(connUrl); - - conn.onopen = () => { - if (this.failCount > 0) { - console.log('websocket re-established connection'); //eslint-disable-line no-console - AsyncClient.getChannels(); - AsyncClient.getPosts(ChannelStore.getCurrentId()); - } - - if (this.isInitialize) { - ErrorStore.clearLastError(); - ErrorStore.emitChange(); - } - - this.isInitialize = true; - this.failCount = 0; - }; - - conn.onclose = () => { - conn = null; - - if (this.failCount === 0) { - console.log('websocket closed'); //eslint-disable-line no-console - } - - this.failCount = this.failCount + 1; - - if (this.failCount > 7) { - ErrorStore.storeLastError({message: this.translations.socketError}); - } - - ErrorStore.setConnectionErrorCount(this.failCount); - ErrorStore.emitChange(); - - setTimeout( - () => { - this.initialize(); - }, - 3000 - ); - }; - - conn.onerror = (evt) => { - if (this.failCount <= 1) { - console.log('websocket error'); //eslint-disable-line no-console - console.log(evt); //eslint-disable-line no-console - } - }; - - conn.onmessage = (evt) => { - const msg = JSON.parse(evt.data); - this.handleMessage(msg); - this.emitChange(msg); - }; - } - } - - emitChange(msg) { - this.emit(CHANGE_EVENT, msg); - } - - addChangeListener(callback) { - this.on(CHANGE_EVENT, callback); - } - - removeChangeListener(callback) { - this.removeListener(CHANGE_EVENT, callback); - } - - handleMessage(msg) { - switch (msg.action) { - case SocketEvents.POSTED: - case SocketEvents.EPHEMERAL_MESSAGE: - handleNewPostEvent(msg, this.translations); - break; - - case SocketEvents.POST_EDITED: - handlePostEditEvent(msg); - break; - - case SocketEvents.POST_DELETED: - handlePostDeleteEvent(msg); - break; - - case SocketEvents.NEW_USER: - handleNewUserEvent(); - break; - - case SocketEvents.USER_ADDED: - handleUserAddedEvent(msg); - break; - - case SocketEvents.USER_REMOVED: - handleUserRemovedEvent(msg); - break; - - case SocketEvents.CHANNEL_VIEWED: - handleChannelViewedEvent(msg); - break; - - case SocketEvents.PREFERENCE_CHANGED: - handlePreferenceChangedEvent(msg); - break; - - default: - } - } - - sendMessage(msg) { - if (conn && conn.readyState === WebSocket.OPEN) { - conn.send(JSON.stringify(msg)); - } else if (!conn || conn.readyState === WebSocket.Closed) { - conn = null; - this.initialize(); - } - } - - setTranslations(messages) { - this.translations = messages; - } - - getDefaultTranslations() { - return ({ - socketError: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.', - someone: 'Someone', - posted: 'Posted', - uploadedImage: ' uploaded an image', - uploadedFile: ' uploaded a file', - something: ' did something new', - wrote: ' wrote: ' - }); - } - - close() { - if (conn && conn.readyState === WebSocket.OPEN) { - conn.close(); - } - } -} - -function handleNewPostEvent(msg, translations) { - // Store post - const post = JSON.parse(msg.props.post); - GlobalActions.emitPostRecievedEvent(post); - - // Update channel state - if (ChannelStore.getCurrentId() === msg.channel_id) { - if (window.isActive) { - AsyncClient.updateLastViewedAt(); - } else { - AsyncClient.getChannel(msg.channel_id); - } - } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) { - AsyncClient.getChannel(msg.channel_id); - } - - // Send desktop notification - if ((UserStore.getCurrentId() !== msg.user_id || post.props.from_webhook === 'true') && !Utils.isSystemMessage(post)) { - const msgProps = msg.props; - - let mentions = []; - if (msgProps.mentions) { - mentions = JSON.parse(msg.props.mentions); - } - - const channel = ChannelStore.get(msg.channel_id); - const user = UserStore.getCurrentUser(); - const member = ChannelStore.getMember(msg.channel_id); - - let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; - if (notifyLevel === 'default') { - notifyLevel = user.notify_props.desktop; - } - - if (notifyLevel === 'none') { - return; - } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== Constants.DM_CHANNEL) { - return; - } - - let username = translations.someone; - if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { - username = post.props.override_username; - } else if (UserStore.hasProfile(msg.user_id)) { - username = UserStore.getProfile(msg.user_id).username; - } - - let title = translations.posted; - if (channel) { - title = channel.display_name; - } - - let notifyText = post.message.replace(/\n+/g, ' '); - if (notifyText.length > 50) { - notifyText = notifyText.substring(0, 49) + '...'; - } - - if (notifyText.length === 0) { - if (msgProps.image) { - Utils.notifyMe(title, username + translations.uploadedImage, channel); - } else if (msgProps.otherFile) { - Utils.notifyMe(title, username + translations.uploadedFile, channel); - } else { - Utils.notifyMe(title, username + translations.something, channel); - } - } else { - Utils.notifyMe(title, username + translations.wrote + notifyText, channel); - } - if (!user.notify_props || user.notify_props.desktop_sound === 'true') { - Utils.ding(); - } - } -} - -function handlePostEditEvent(msg) { - // Store post - const post = JSON.parse(msg.props.post); - PostStore.storePost(post); - PostStore.emitChange(); - - // Update channel state - if (ChannelStore.getCurrentId() === msg.channel_id) { - if (window.isActive) { - AsyncClient.updateLastViewedAt(); - } - } -} - -function handlePostDeleteEvent(msg) { - const post = JSON.parse(msg.props.post); - GlobalActions.emitPostDeletedEvent(post); -} - -function handleNewUserEvent() { - AsyncClient.getProfiles(); - AsyncClient.getChannelExtraInfo(); -} - -function handleUserAddedEvent(msg) { - if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(); - } - - if (UserStore.getCurrentId() === msg.user_id) { - AsyncClient.getChannel(msg.channel_id); - } -} - -function handleUserRemovedEvent(msg) { - if (UserStore.getCurrentId() === msg.user_id) { - AsyncClient.getChannels(); - - if (msg.props.remover_id !== msg.user_id && - msg.channel_id === ChannelStore.getCurrentId() && - $('#removed_from_channel').length > 0) { - var sentState = {}; - sentState.channelName = ChannelStore.getCurrent().display_name; - sentState.remover = UserStore.getProfile(msg.props.remover_id).username; - - BrowserStore.setItem('channel-removed-state', sentState); - $('#removed_from_channel').modal('show'); - } - } else if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(); - } -} - -function handleChannelViewedEvent(msg) { - // Useful for when multiple devices have the app open to different channels - if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) { - AsyncClient.getChannel(msg.channel_id); - } -} - -function handlePreferenceChangedEvent(msg) { - const preference = JSON.parse(msg.props.preference); - GlobalActions.emitPreferenceChangedEvent(preference); -} - -var SocketStore = new SocketStoreClass(); - -export default SocketStore; -window.SocketStore = SocketStore; diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx index 98cc2f3f1..4213e6e8c 100644 --- a/webapp/stores/user_store.jsx +++ b/webapp/stores/user_store.jsx @@ -16,7 +16,10 @@ const CHANGE_EVENT_STATUSES = 'change_statuses'; class UserStoreClass extends EventEmitter { constructor() { super(); - this.profileCache = null; + this.profiles = {}; + this.statuses = {}; + this.sessions = {}; + this.audits = {}; this.currentUserId = ''; } @@ -135,11 +138,7 @@ class UserStoreClass extends EventEmitter { } getProfiles() { - if (this.profileCache !== null) { - return this.profileCache; - } - - return BrowserStore.getItem('profiles', {}); + return this.profiles; } getActiveOnlyProfiles(skipCurrent) { @@ -171,47 +170,38 @@ class UserStoreClass extends EventEmitter { } saveProfile(profile) { - var ps = this.getProfiles(); - ps[profile.id] = profile; - this.profileCache = ps; - BrowserStore.setItem('profiles', ps); + this.profiles[profile.id] = profile; } saveProfiles(profiles) { const currentId = this.getCurrentId(); - if (this.profileCache) { - const currentUser = this.profileCache[currentId]; - if (currentUser) { - if (currentId in profiles) { - delete profiles[currentId]; - } - - this.profileCache = profiles; - this.profileCache[currentId] = currentUser; - } else { - this.profileCache = profiles; + const currentUser = this.profiles[currentId]; + if (currentUser) { + if (currentId in this.profiles) { + delete this.profiles[currentId]; } + + this.profiles = profiles; + this.profiles[currentId] = currentUser; } else { - this.profileCache = profiles; + this.profiles = profiles; } - - BrowserStore.setItem('profiles', profiles); } setSessions(sessions) { - BrowserStore.setItem('sessions', sessions); + this.sessions = sessions; } getSessions() { - return BrowserStore.getItem('sessions', {loading: true}); + return this.sessions; } setAudits(audits) { - BrowserStore.setItem('audits', audits); + this.audits = audits; } getAudits() { - return BrowserStore.getItem('audits', {loading: true}); + return this.audits; } getCurrentMentionKeys() { @@ -252,7 +242,7 @@ class UserStoreClass extends EventEmitter { } pSetStatuses(statuses) { - BrowserStore.setItem('statuses', statuses); + this.statuses = statuses; } setStatus(userId, status) { @@ -263,7 +253,7 @@ class UserStoreClass extends EventEmitter { } getStatuses() { - return BrowserStore.getItem('statuses', {}); + return this.statuses; } getStatus(id) { diff --git a/webapp/stores/user_typing_store.jsx b/webapp/stores/user_typing_store.jsx new file mode 100644 index 000000000..ab0a9af1d --- /dev/null +++ b/webapp/stores/user_typing_store.jsx @@ -0,0 +1,108 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import UserStore from 'stores/user_store.jsx'; +import EventEmitter from 'events'; +import * as Utils from 'utils/utils.jsx'; + +import Constants from 'utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; + +const CHANGE_EVENT = 'change'; + +class UserTypingStoreClass extends EventEmitter { + constructor() { + super(); + + // All typeing users by channel + // this.typingUsers.[channelId+postParentId].user if present then user us typing + // Value is timeout to remove user + this.typingUsers = {}; + } + + emitChange() { + this.emit(CHANGE_EVENT); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + + usernameFromId(userId) { + let username = Utils.localizeMessage('msg_typing.someone', 'Someone'); + if (UserStore.hasProfile(userId)) { + username = UserStore.getProfile(userId).username; + } + return username; + } + + userTyping(channelId, userId, postParentId) { + const username = this.usernameFromId(userId); + + // Key representing a location where users can type + const loc = channelId + postParentId; + + // Create entry + if (!this.typingUsers[loc]) { + this.typingUsers[loc] = {}; + } + + // If we already have this user, clear it's timeout to be deleted + if (this.typingUsers[loc][username]) { + clearTimeout(this.typingUsers[loc][username].timeout); + } + + // Set the user and a timeout to remove it + this.typingUsers[loc][username] = setTimeout(() => { + delete this.typingUsers[loc][username]; + if (this.typingUsers[loc] === {}) { + delete this.typingUsers[loc]; + } + this.emitChange(); + }, Constants.UPDATE_TYPING_MS); + this.emitChange(); + } + + getUsersTyping(channelId, postParentId) { + // Key representing a location where users can type + const loc = channelId + postParentId; + + return this.typingUsers[loc]; + } + + userPosted(userId, channelId, postParentId) { + const username = this.usernameFromId(userId); + const loc = channelId + postParentId; + + if (this.typingUsers[loc]) { + clearTimeout(this.typingUsers[loc][username]); + delete this.typingUsers[loc][username]; + if (this.typingUsers[loc] === {}) { + delete this.typingUsers[loc]; + } + this.emitChange(); + } + } +} + +var UserTypingStore = new UserTypingStoreClass(); + +UserTypingStore.dispatchToken = AppDispatcher.register((payload) => { + var action = payload.action; + + switch (action.type) { + case ActionTypes.RECEIVED_POST: + UserTypingStore.userPosted(action.post.user_id, action.post.channel_id, action.post.parent_id); + break; + case ActionTypes.USER_TYPING: + UserTypingStore.userTyping(action.channelId, action.userId, action.postParentId); + break; + } +}); + +export default UserTypingStore; diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index d3f91bb0e..6140fd9e0 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -673,9 +673,9 @@ export function getStatuses() { const preferences = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); const teammateIds = []; - for (const preference of preferences) { - if (preference.value === 'true') { - teammateIds.push(preference.name); + for (const [name, value] of preferences) { + if (value === 'true') { + teammateIds.push(name); } } @@ -756,6 +756,17 @@ export function getAllPreferences() { ); } +export function savePreference(category, name, value, success, error) { + const preference = { + user_id: UserStore.getCurrentId(), + category, + name, + value + }; + + savePreferences([preference], success, error); +} + export function savePreferences(preferences, success, error) { client.savePreferences( preferences, diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index a4aa7604c..859348c73 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -83,6 +83,8 @@ export default { SHOW_SEARCH: null, + USER_TYPING: null, + TOGGLE_IMPORT_THEME_MODAL: null, TOGGLE_INVITE_MEMBER_MODAL: null, TOGGLE_DELETE_POST_MODAL: null, @@ -429,6 +431,21 @@ export default { uiName: 'Mention Highlight Link' }, { + group: 'linkAndButtonElements', + id: 'linkColor', + uiName: 'Link Color' + }, + { + group: 'linkAndButtonElements', + id: 'buttonBg', + uiName: 'Button BG' + }, + { + group: 'linkAndButtonElements', + id: 'buttonColor', + uiName: 'Button Text' + }, + { group: 'centerChannelElements', id: 'codeTheme', uiName: 'Code Theme', @@ -458,21 +475,6 @@ export default { iconURL: monokaiIcon } ] - }, - { - group: 'linkAndButtonElements', - id: 'linkColor', - uiName: 'Link Color' - }, - { - group: 'linkAndButtonElements', - id: 'buttonBg', - uiName: 'Button BG' - }, - { - group: 'linkAndButtonElements', - id: 'buttonColor', - uiName: 'Button Text' } ], DEFAULT_CODE_THEME: 'github', diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 95a0f99d5..1379455ca 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -314,7 +314,13 @@ export function getTimestamp() { // extracts links not styled by Markdown export function extractLinks(text) { - const links = []; + text; // eslint-disable-line no-unused-expressions + Autolinker; // eslint-disable-line no-unused-expressions + + // skip this operation because autolinker is having issues + return []; + + /*const links = []; let inText = text; // strip out code blocks @@ -348,7 +354,7 @@ export function extractLinks(text) { } ); - return links; + return links;*/ } export function escapeRegExp(string) { @@ -681,7 +687,7 @@ export function applyTheme(theme) { } if (theme.centerChannelBg) { - changeCss('.app__content, .markdown__table, .markdown__table tbody tr, .suggestion-list__content, .modal .modal-content, .modal .modal-back', 'background:' + theme.centerChannelBg, 1); + changeCss('.app__content, .markdown__table, .markdown__table tbody tr, .suggestion-list__content, .modal .modal-content', 'background:' + theme.centerChannelBg, 1); changeCss('#post-list .post-list-holder-by-time', 'background:' + theme.centerChannelBg, 1); changeCss('#post-create', 'background:' + theme.centerChannelBg, 1); changeCss('.date-separator .separator__text, .new-separator .separator__text', 'background:' + theme.centerChannelBg, 1); |