summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--STYLE-GUIDE.md14
-rw-r--r--api/channel.go2
-rw-r--r--api/context.go21
-rw-r--r--api/file.go1
-rw-r--r--api/post.go38
-rw-r--r--api/post_test.go70
-rw-r--r--api/slackimport.go28
-rw-r--r--api/team.go46
-rw-r--r--api/templates/error.html1
-rw-r--r--api/templates/find_teams_body.html2
-rw-r--r--api/web_conn.go5
-rw-r--r--api/web_hub.go8
-rw-r--r--api/web_team_hub.go9
-rw-r--r--config/config.json8
-rw-r--r--docker/dev/config_docker.json4
-rw-r--r--docker/local/config_docker.json4
-rw-r--r--model/client.go9
-rw-r--r--model/post.go4
-rw-r--r--store/sql_post_store.go81
-rw-r--r--store/sql_post_store_test.go85
-rw-r--r--store/store.go1
-rw-r--r--utils/config.go22
-rw-r--r--utils/mail.go2
-rw-r--r--web/react/components/channel_loader.jsx54
-rw-r--r--web/react/components/create_comment.jsx14
-rw-r--r--web/react/components/create_post.jsx12
-rw-r--r--web/react/components/delete_post_modal.jsx3
-rw-r--r--web/react/components/edit_post_modal.jsx12
-rw-r--r--web/react/components/post.jsx41
-rw-r--r--web/react/components/post_list.jsx831
-rw-r--r--web/react/components/post_right.jsx404
-rw-r--r--web/react/components/rhs_comment.jsx207
-rw-r--r--web/react/components/rhs_header_post.jsx81
-rw-r--r--web/react/components/rhs_root_post.jsx145
-rw-r--r--web/react/components/rhs_thread.jsx215
-rw-r--r--web/react/components/setting_upload.jsx18
-rw-r--r--web/react/components/sidebar.jsx3
-rw-r--r--web/react/components/sidebar_right.jsx12
-rw-r--r--web/react/components/sidebar_right_menu.jsx7
-rw-r--r--web/react/components/team_import_tab.jsx14
-rw-r--r--web/react/components/team_signup_password_page.jsx38
-rw-r--r--web/react/components/textbox.jsx2
-rw-r--r--web/react/stores/post_store.jsx125
-rw-r--r--web/react/utils/async_client.jsx60
-rw-r--r--web/react/utils/client.jsx17
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/utils.jsx55
-rw-r--r--web/sass-files/sass/partials/_post.scss3
49 files changed, 1860 insertions, 981 deletions
diff --git a/README.md b/README.md
index 9d8e5dfd3..1dd9620d6 100644
--- a/README.md
+++ b/README.md
@@ -205,7 +205,7 @@ Contributing
To contribute to this open source project please review the [Mattermost Contribution Guidelines]( http://www.mattermost.org/contribute-to-mattermost/).
-To setup your machine for development of mattermost see: [Developer Machine Setup](scripts/README_DEV.md)
+To setup your machine for development of mattermost see: [Developer Machine Setup](scripts/README_DEV.md).
License
-------
diff --git a/STYLE-GUIDE.md b/STYLE-GUIDE.md
index 0da0a14f8..470788cf5 100644
--- a/STYLE-GUIDE.md
+++ b/STYLE-GUIDE.md
@@ -22,8 +22,8 @@ The following is an abridged version of the [Airbnb Javascript Style Guide](http
### Whitespace
-- Indentation is four spaces
-- Use a space before the leading brace
+- Indentation is four spaces.
+- Use a space before the leading brace.
- Use one space between the comma and the next argument in a bracketed list. No other space.
- Use whitespace to make code more readable.
- Do not use more than one newline to separate code blocks.
@@ -49,7 +49,7 @@ function myFunction ( parm1, parm2 ){
### Semicolons
-- You must use them always
+- You must use them always.
```javascript
// Correct
@@ -85,14 +85,14 @@ var my_variable = 4;
```javascript
// Correct
-if (somthing) {
+if (something) {
stuff...;
} else if (otherthing) {
stuff...;
}
// Incorrect
-if (somthing)
+if (something)
{
stuff...;
}
@@ -102,8 +102,8 @@ else
}
// Incorrect
-if (somthing) stuff...;
-if (somthing)
+if (something) stuff...;
+if (something)
stuff...;
```
diff --git a/api/channel.go b/api/channel.go
index 5f3282072..b40366719 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -391,6 +391,8 @@ func JoinChannel(c *Context, channelId string, role string) {
c.Err = model.NewAppError("joinChannel", "Failed to send join request", "")
return
}
+
+ UpdateChannelAccessCacheAndForget(c.Session.TeamId, c.Session.UserId, channel.Id)
} else {
c.Err = model.NewAppError("joinChannel", "You do not have the appropriate permissions", "")
c.Err.StatusCode = http.StatusForbidden
diff --git a/api/context.go b/api/context.go
index 8babf85f2..aaf304e2c 100644
--- a/api/context.go
+++ b/api/context.go
@@ -4,14 +4,15 @@
package api
import (
- l4g "code.google.com/p/log4go"
- "github.com/mattermost/platform/model"
- "github.com/mattermost/platform/store"
- "github.com/mattermost/platform/utils"
"net"
"net/http"
"net/url"
"strings"
+
+ l4g "code.google.com/p/log4go"
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
+ "github.com/mattermost/platform/utils"
)
var sessionCache *utils.Cache = utils.NewLru(model.SESSION_CACHE_SIZE)
@@ -431,10 +432,22 @@ func IsPrivateIpAddress(ipAddress string) bool {
}
func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) {
+
+ protocol := "http"
+ if utils.Cfg.ServiceSettings.UseSSL {
+ forwardProto := r.Header.Get(model.HEADER_FORWARDED_PROTO)
+ if forwardProto != "http" {
+ protocol = "https"
+ }
+ }
+
+ SiteURL := protocol + "://" + r.Host
+
m := make(map[string]string)
m["Message"] = err.Message
m["Details"] = err.DetailedError
m["SiteName"] = utils.Cfg.ServiceSettings.SiteName
+ m["SiteURL"] = SiteURL
w.WriteHeader(err.StatusCode)
ServerTemplates.ExecuteTemplate(w, "error.html", m)
diff --git a/api/file.go b/api/file.go
index 50482a057..800c512c5 100644
--- a/api/file.go
+++ b/api/file.go
@@ -347,6 +347,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "max-age=2592000, public")
+ w.Header().Set("Content-Length", strconv.Itoa(len(f)))
w.Write(f)
}
diff --git a/api/post.go b/api/post.go
index c013df87f..5363fdf79 100644
--- a/api/post.go
+++ b/api/post.go
@@ -28,6 +28,7 @@ func InitPost(r *mux.Router) {
sr.Handle("/valet_create", ApiUserRequired(createValetPost)).Methods("POST")
sr.Handle("/update", ApiUserRequired(updatePost)).Methods("POST")
sr.Handle("/posts/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getPosts, false)).Methods("GET")
+ sr.Handle("/posts/{time:[0-9]+}", ApiUserRequiredActivity(getPostsSince, false)).Methods("GET")
sr.Handle("/post/{post_id:[A-Za-z0-9]+}", ApiUserRequired(getPost)).Methods("GET")
sr.Handle("/post/{post_id:[A-Za-z0-9]+}/delete", ApiUserRequired(deletePost)).Methods("POST")
}
@@ -545,9 +546,7 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
rpost := result.Data.(*model.Post)
message := model.NewMessage(c.Session.TeamId, rpost.ChannelId, c.Session.UserId, model.ACTION_POST_EDITED)
- message.Add("post_id", rpost.Id)
- message.Add("channel_id", rpost.ChannelId)
- message.Add("message", rpost.Message)
+ message.Add("post", rpost.ToJson())
PublishAndForget(message)
@@ -603,6 +602,39 @@ func getPosts(c *Context, w http.ResponseWriter, r *http.Request) {
}
+func getPostsSince(c *Context, w http.ResponseWriter, r *http.Request) {
+ params := mux.Vars(r)
+
+ id := params["id"]
+ if len(id) != 26 {
+ c.SetInvalidParam("getPostsSince", "channelId")
+ return
+ }
+
+ time, err := strconv.ParseInt(params["time"], 10, 64)
+ if err != nil {
+ c.SetInvalidParam("getPostsSince", "time")
+ return
+ }
+
+ cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, id, c.Session.UserId)
+ pchan := Srv.Store.Post().GetPostsSince(id, time)
+
+ if !c.HasPermissionsToChannel(cchan, "getPostsSince") {
+ return
+ }
+
+ if result := <-pchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ list := result.Data.(*model.PostList)
+
+ w.Write([]byte(list.ToJson()))
+ }
+
+}
+
func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
diff --git a/api/post_test.go b/api/post_test.go
index af2e4f326..85d92de3a 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -351,6 +351,76 @@ func TestGetPosts(t *testing.T) {
}
}
+func TestGetPostsSince(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user1.Id))
+
+ Client.LoginByEmail(team.Name, user1.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ time.Sleep(10 * time.Millisecond)
+ post0 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
+ post0 = Client.Must(Client.CreatePost(post0)).Data.(*model.Post)
+
+ time.Sleep(10 * time.Millisecond)
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
+ post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+
+ time.Sleep(10 * time.Millisecond)
+ post1a1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a", RootId: post1.Id}
+ post1a1 = Client.Must(Client.CreatePost(post1a1)).Data.(*model.Post)
+
+ time.Sleep(10 * time.Millisecond)
+ post2 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
+ post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
+
+ time.Sleep(10 * time.Millisecond)
+ post3 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
+ post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post)
+
+ time.Sleep(10 * time.Millisecond)
+ post3a1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a", RootId: post3.Id}
+ post3a1 = Client.Must(Client.CreatePost(post3a1)).Data.(*model.Post)
+
+ r1 := Client.Must(Client.GetPostsSince(channel1.Id, post1.CreateAt)).Data.(*model.PostList)
+
+ if r1.Order[0] != post3a1.Id {
+ t.Fatal("wrong order")
+ }
+
+ if r1.Order[1] != post3.Id {
+ t.Fatal("wrong order")
+ }
+
+ if len(r1.Posts) != 5 {
+ t.Fatal("wrong size")
+ }
+
+ now := model.GetMillis()
+ r2 := Client.Must(Client.GetPostsSince(channel1.Id, now)).Data.(*model.PostList)
+
+ if len(r2.Posts) != 0 {
+ t.Fatal("should have been empty")
+ }
+
+ post2.Message = "new message"
+ Client.Must(Client.UpdatePost(post2))
+
+ r3 := Client.Must(Client.GetPostsSince(channel1.Id, now)).Data.(*model.PostList)
+
+ if len(r3.Order) != 2 { // 2 because deleted post is returned as well
+ t.Fatal("missing post update")
+ }
+}
+
func TestSearchPosts(t *testing.T) {
Setup()
diff --git a/api/slackimport.go b/api/slackimport.go
index ca3fdf3d1..1d037a934 100644
--- a/api/slackimport.go
+++ b/api/slackimport.go
@@ -82,8 +82,8 @@ func SlackParsePosts(data io.Reader) []SlackPost {
func SlackAddUsers(teamId string, slackusers []SlackUser, log *bytes.Buffer) map[string]*model.User {
// Log header
- log.WriteString("\n Users Created\n")
- log.WriteString("===============\n\n")
+ log.WriteString("\r\n Users Created\r\n")
+ log.WriteString("===============\r\n\r\n")
addedUsers := make(map[string]*model.User)
for _, sUser := range slackusers {
@@ -109,9 +109,9 @@ func SlackAddUsers(teamId string, slackusers []SlackUser, log *bytes.Buffer) map
if mUser := ImportUser(&newUser); mUser != nil {
addedUsers[sUser.Id] = mUser
- log.WriteString("Email, Password: " + newUser.Email + ", " + password + "\n")
+ log.WriteString("Email, Password: " + newUser.Email + ", " + password + "\r\n")
} else {
- log.WriteString("Unable to import user: " + sUser.Username)
+ log.WriteString("Unable to import user: " + sUser.Username + "\r\n")
}
}
@@ -163,8 +163,8 @@ func SlackAddPosts(channel *model.Channel, posts []SlackPost, users map[string]*
func SlackAddChannels(teamId string, slackchannels []SlackChannel, posts map[string][]SlackPost, users map[string]*model.User, log *bytes.Buffer) map[string]*model.Channel {
// Write Header
- log.WriteString("\n Channels Added \n")
- log.WriteString("=================\n\n")
+ log.WriteString("\r\n Channels Added \r\n")
+ log.WriteString("=================\r\n\r\n")
addedChannels := make(map[string]*model.Channel)
for _, sChannel := range slackchannels {
@@ -180,14 +180,14 @@ func SlackAddChannels(teamId string, slackchannels []SlackChannel, posts map[str
// Maybe it already exists?
if result := <-Srv.Store.Channel().GetByName(teamId, sChannel.Name); result.Err != nil {
l4g.Debug("Failed to import: %s", newChannel.DisplayName)
- log.WriteString("Failed to import: " + newChannel.DisplayName + "\n")
+ log.WriteString("Failed to import: " + newChannel.DisplayName + "\r\n")
continue
} else {
mChannel = result.Data.(*model.Channel)
- log.WriteString("Merged with existing channel: " + newChannel.DisplayName + "\n")
+ log.WriteString("Merged with existing channel: " + newChannel.DisplayName + "\r\n")
}
}
- log.WriteString(newChannel.DisplayName + "\n")
+ log.WriteString(newChannel.DisplayName + "\r\n")
addedChannels[sChannel.Id] = mChannel
SlackAddPosts(mChannel, posts[sChannel.Name], users)
}
@@ -202,7 +202,7 @@ func SlackImport(fileData multipart.File, fileSize int64, teamID string) (*model
}
// Create log file
- log := bytes.NewBufferString("Mattermost Slack Import Log\n")
+ log := bytes.NewBufferString("Mattermost Slack Import Log\r\n")
var channels []SlackChannel
var users []SlackUser
@@ -234,11 +234,11 @@ func SlackImport(fileData multipart.File, fileSize int64, teamID string) (*model
addedUsers := SlackAddUsers(teamID, users, log)
SlackAddChannels(teamID, channels, posts, addedUsers, log)
- log.WriteString("\n Notes \n")
- log.WriteString("=======\n\n")
+ log.WriteString("\r\n Notes \r\n")
+ log.WriteString("=======\r\n\r\n")
- log.WriteString("- Some posts may not have been imported because they where not supported by this importer.\n")
- log.WriteString("- Slack bot posts are currently not supported.\n")
+ log.WriteString("- Some posts may not have been imported because they where not supported by this importer.\r\n")
+ log.WriteString("- Slack bot posts are currently not supported.\r\n")
return nil, log
}
diff --git a/api/team.go b/api/team.go
index a331e9e34..eaa0d2695 100644
--- a/api/team.go
+++ b/api/team.go
@@ -44,6 +44,10 @@ func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ if !isTreamCreationAllowed(c, email) {
+ return
+ }
+
subjectPage := NewServerTemplatePage("signup_team_subject", c.GetSiteURL())
bodyPage := NewServerTemplatePage("signup_team_body", c.GetSiteURL())
bodyPage.Props["TourUrl"] = utils.Cfg.TeamSettings.TourLink
@@ -89,6 +93,11 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
+
+ if !isTreamCreationAllowed(c, teamSignup.Team.Email) {
+ return
+ }
+
teamSignup.Team.Id = ""
password := teamSignup.User.Password
@@ -169,6 +178,10 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ if !isTreamCreationAllowed(c, team.Email) {
+ return
+ }
+
if utils.Cfg.ServiceSettings.Mode != utils.MODE_DEV {
c.Err = model.NewAppError("createTeam", "The mode does not allow network creation without a valid invite", "")
return
@@ -196,6 +209,35 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+func isTreamCreationAllowed(c *Context, email string) bool {
+
+ email = strings.ToLower(email)
+
+ if utils.Cfg.TeamSettings.DisableTeamCreation {
+ c.Err = model.NewAppError("isTreamCreationAllowed", "Team creation has been disabled. Please ask your systems administrator for details.", "")
+ return false
+ }
+
+ // commas and @ signs are optional
+ // can be in the form of "@corp.mattermost.com, mattermost.com mattermost.org" -> corp.mattermost.com mattermost.com mattermost.org
+ domains := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(utils.Cfg.TeamSettings.RestrictCreationToDomains, "@", " ", -1), ",", " ", -1))))
+
+ matched := false
+ for _, d := range domains {
+ if strings.HasSuffix(email, "@"+d) {
+ matched = true
+ break
+ }
+ }
+
+ if len(utils.Cfg.TeamSettings.RestrictCreationToDomains) > 0 && !matched {
+ c.Err = model.NewAppError("isTreamCreationAllowed", "Email must be from a specific domain (e.g. @example.com). Please ask your systems administrator for details.", "")
+ return false
+ }
+
+ return true
+}
+
func findTeamByName(c *Context, w http.ResponseWriter, r *http.Request) {
m := model.MapFromJson(r.Body)
@@ -283,10 +325,10 @@ func emailTeams(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
teams := result.Data.([]*model.Team)
- // the template expects Props to be a map with team names as the keys
+ // the template expects Props to be a map with team names as the keys and the team url as the value
props := make(map[string]string)
for _, team := range teams {
- props[team.Name] = team.Name
+ props[team.Name] = c.GetTeamURLFromTeam(team)
}
bodyPage.Props = props
diff --git a/api/templates/error.html b/api/templates/error.html
index f38bb81a1..3474c9e1e 100644
--- a/api/templates/error.html
+++ b/api/templates/error.html
@@ -14,6 +14,7 @@
<div class="error__icon"><i class="fa fa-exclamation-triangle"></i></div>
<h2>{{ .SiteName }} needs your help:</h2>
<p>{{.Message}}</p>
+ <a href="{{.SiteURL}}">Go back to team site</a>
</div>
</div>
</body>
diff --git a/api/templates/find_teams_body.html b/api/templates/find_teams_body.html
index bd151a819..64bff8126 100644
--- a/api/templates/find_teams_body.html
+++ b/api/templates/find_teams_body.html
@@ -21,7 +21,7 @@
<p>{{ if .Props }}
The following teams were found:<br>
{{range $index, $element := .Props}}
- {{ $index }}<br>
+ <a href="{{ $element }}" style="text-decoration: none; color:#2389D7;">{{ $index }}</a><br>
{{ end }}
{{ else }}
We could not find any teams for the given email.
diff --git a/api/web_conn.go b/api/web_conn.go
index 0990de8ef..4315f5650 100644
--- a/api/web_conn.go
+++ b/api/web_conn.go
@@ -121,6 +121,11 @@ func (c *WebConn) writePump() {
}
}
+func (c *WebConn) updateChannelAccessCache(channelId string) {
+ allowed := hasPermissionsToChannel(Srv.Store.Channel().CheckPermissionsTo(c.TeamId, channelId, c.UserId))
+ c.ChannelAccessCache[channelId] = allowed
+}
+
func hasPermissionsToChannel(sc store.StoreChannel) bool {
if cresult := <-sc; cresult.Err != nil {
return false
diff --git a/api/web_hub.go b/api/web_hub.go
index c7be19cac..44d405283 100644
--- a/api/web_hub.go
+++ b/api/web_hub.go
@@ -30,6 +30,14 @@ func PublishAndForget(message *model.Message) {
}()
}
+func UpdateChannelAccessCacheAndForget(teamId, userId, channelId string) {
+ go func() {
+ if nh, ok := hub.teamHubs[teamId]; ok {
+ nh.UpdateChannelAccessCache(userId, channelId)
+ }
+ }()
+}
+
func (h *Hub) Register(webConn *WebConn) {
h.register <- webConn
}
diff --git a/api/web_team_hub.go b/api/web_team_hub.go
index 7a63b84d1..31c8dfedf 100644
--- a/api/web_team_hub.go
+++ b/api/web_team_hub.go
@@ -77,3 +77,12 @@ func (h *TeamHub) Start() {
}
}()
}
+
+func (h *TeamHub) UpdateChannelAccessCache(userId string, channelId string) {
+ for webCon := range h.connections {
+ if webCon.UserId == userId {
+ webCon.updateChannelAccessCache(channelId)
+ break
+ }
+ }
+}
diff --git a/config/config.json b/config/config.json
index f1f3ba22c..6c915e290 100644
--- a/config/config.json
+++ b/config/config.json
@@ -73,8 +73,8 @@
"SMTPUsername": "",
"SMTPPassword": "",
"SMTPServer": "",
- "UseTLS": false,
- "UseStartTLS": false,
+ "UseTLS": false,
+ "UseStartTLS": false,
"FeedbackEmail": "",
"FeedbackName": "",
"ApplePushServer": "",
@@ -104,6 +104,8 @@
"HelpLink": "/static/help/configure_links.html",
"ReportProblemLink": "/static/help/configure_links.html",
"TourLink": "/static/help/configure_links.html",
- "DefaultThemeColor": "#2389D7"
+ "DefaultThemeColor": "#2389D7",
+ "DisableTeamCreation": false,
+ "RestrictCreationToDomains": ""
}
}
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index d336300ca..0fa51cfd4 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -94,6 +94,8 @@
"HelpLink": "/static/help/configure_links.html",
"ReportProblemLink": "/static/help/configure_links.html",
"TourLink": "/static/help/configure_links.html",
- "DefaultThemeColor": "#2389D7"
+ "DefaultThemeColor": "#2389D7",
+ "DisableTeamCreation": false,
+ "RestrictCreationToDomains": ""
}
}
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index d336300ca..0fa51cfd4 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -94,6 +94,8 @@
"HelpLink": "/static/help/configure_links.html",
"ReportProblemLink": "/static/help/configure_links.html",
"TourLink": "/static/help/configure_links.html",
- "DefaultThemeColor": "#2389D7"
+ "DefaultThemeColor": "#2389D7",
+ "DisableTeamCreation": false,
+ "RestrictCreationToDomains": ""
}
}
diff --git a/model/client.go b/model/client.go
index 9fcb06cf8..c355b90f5 100644
--- a/model/client.go
+++ b/model/client.go
@@ -522,6 +522,15 @@ func (c *Client) GetPosts(channelId string, offset int, limit int, etag string)
}
}
+func (c *Client) GetPostsSince(channelId string, time int64) (*Result, *AppError) {
+ if r, err := c.DoGet(fmt.Sprintf("/channels/%v/posts/%v", channelId, time), "", ""); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), PostListFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) GetPost(channelId string, postId string, etag string) (*Result, *AppError) {
if r, err := c.DoGet(fmt.Sprintf("/channels/%v/post/%v", channelId, postId), "", etag); err != nil {
return nil, err
diff --git a/model/post.go b/model/post.go
index f8a3032a3..0c035d4e7 100644
--- a/model/post.go
+++ b/model/post.go
@@ -120,9 +120,7 @@ func (o *Post) PreSave() {
o.OriginalId = ""
- if o.CreateAt <= 0 {
- o.CreateAt = GetMillis()
- }
+ o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
if o.Props == nil {
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 78361270e..a94a1c014 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -7,7 +7,6 @@ import (
"fmt"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
- "strconv"
"strings"
)
@@ -158,14 +157,6 @@ func (s SqlPostStore) Get(id string) StoreChannel {
result.Err = model.NewAppError("SqlPostStore.GetPost", "We couldn't get the post", "id="+id+err.Error())
}
- if post.ImgCount > 0 {
- post.Filenames = []string{}
- for i := 0; int64(i) < post.ImgCount; i++ {
- fileUrl := "/api/v1/files/get_image/" + post.ChannelId + "/" + post.Id + "/" + strconv.Itoa(i+1) + ".png"
- post.Filenames = append(post.Filenames, fileUrl)
- }
- }
-
pl.AddPost(&post)
pl.AddOrder(id)
@@ -265,25 +256,11 @@ func (s SqlPostStore) GetPosts(channelId string, offset int, limit int) StoreCha
list := &model.PostList{Order: make([]string, 0, len(posts))}
for _, p := range posts {
- if p.ImgCount > 0 {
- p.Filenames = []string{}
- for i := 0; int64(i) < p.ImgCount; i++ {
- fileUrl := "/api/v1/files/get_image/" + p.ChannelId + "/" + p.Id + "/" + strconv.Itoa(i+1) + ".png"
- p.Filenames = append(p.Filenames, fileUrl)
- }
- }
list.AddPost(p)
list.AddOrder(p.Id)
}
for _, p := range parents {
- if p.ImgCount > 0 {
- p.Filenames = []string{}
- for i := 0; int64(i) < p.ImgCount; i++ {
- fileUrl := "/api/v1/files/get_image/" + p.ChannelId + "/" + p.Id + "/" + strconv.Itoa(i+1) + ".png"
- p.Filenames = append(p.Filenames, fileUrl)
- }
- }
list.AddPost(p)
}
@@ -299,6 +276,64 @@ func (s SqlPostStore) GetPosts(channelId string, offset int, limit int) StoreCha
return storeChannel
}
+func (s SqlPostStore) GetPostsSince(channelId string, time int64) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var posts []*model.Post
+ _, err := s.GetReplica().Select(&posts,
+ `(SELECT
+ *
+ FROM
+ Posts
+ WHERE
+ (UpdateAt > :Time
+ AND ChannelId = :ChannelId)
+ LIMIT 1000)
+ UNION
+ (SELECT
+ *
+ FROM
+ Posts
+ WHERE
+ Id
+ IN
+ (SELECT * FROM (SELECT
+ RootId
+ FROM
+ Posts
+ WHERE
+ UpdateAt > :Time
+ AND ChannelId = :ChannelId
+ LIMIT 1000) temp_tab))
+ ORDER BY CreateAt DESC`,
+ map[string]interface{}{"ChannelId": channelId, "Time": time})
+
+ if err != nil {
+ result.Err = model.NewAppError("SqlPostStore.GetPostsSince", "We couldn't get the posts for the channel", "channelId="+channelId+err.Error())
+ } else {
+
+ list := &model.PostList{Order: make([]string, 0, len(posts))}
+
+ for _, p := range posts {
+ list.AddPost(p)
+ if p.UpdateAt > time {
+ list.AddOrder(p.Id)
+ }
+ }
+
+ result.Data = list
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlPostStore) getRootPosts(channelId string, offset int, limit int) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index 5855607fd..4564e2deb 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -383,6 +383,91 @@ func TestPostStoreGetPostsWtihDetails(t *testing.T) {
}
}
+func TestPostStoreGetPostsSince(t *testing.T) {
+ Setup()
+ o0 := &model.Post{}
+ o0.ChannelId = model.NewId()
+ o0.UserId = model.NewId()
+ o0.Message = "a" + model.NewId() + "b"
+ o0 = (<-store.Post().Save(o0)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o1 := &model.Post{}
+ o1.ChannelId = model.NewId()
+ o1.UserId = model.NewId()
+ o1.Message = "a" + model.NewId() + "b"
+ o1 = (<-store.Post().Save(o1)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o2 := &model.Post{}
+ o2.ChannelId = o1.ChannelId
+ o2.UserId = model.NewId()
+ o2.Message = "a" + model.NewId() + "b"
+ o2.ParentId = o1.Id
+ o2.RootId = o1.Id
+ o2 = (<-store.Post().Save(o2)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o2a := &model.Post{}
+ o2a.ChannelId = o1.ChannelId
+ o2a.UserId = model.NewId()
+ o2a.Message = "a" + model.NewId() + "b"
+ o2a.ParentId = o1.Id
+ o2a.RootId = o1.Id
+ o2a = (<-store.Post().Save(o2a)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o3 := &model.Post{}
+ o3.ChannelId = o1.ChannelId
+ o3.UserId = model.NewId()
+ o3.Message = "a" + model.NewId() + "b"
+ o3.ParentId = o1.Id
+ o3.RootId = o1.Id
+ o3 = (<-store.Post().Save(o3)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o4 := &model.Post{}
+ o4.ChannelId = o1.ChannelId
+ o4.UserId = model.NewId()
+ o4.Message = "a" + model.NewId() + "b"
+ o4 = (<-store.Post().Save(o4)).Data.(*model.Post)
+ time.Sleep(2 * time.Millisecond)
+
+ o5 := &model.Post{}
+ o5.ChannelId = o1.ChannelId
+ o5.UserId = model.NewId()
+ o5.Message = "a" + model.NewId() + "b"
+ o5.ParentId = o4.Id
+ o5.RootId = o4.Id
+ o5 = (<-store.Post().Save(o5)).Data.(*model.Post)
+
+ r1 := (<-store.Post().GetPostsSince(o1.ChannelId, o1.CreateAt)).Data.(*model.PostList)
+
+ if r1.Order[0] != o5.Id {
+ t.Fatal("invalid order")
+ }
+
+ if r1.Order[1] != o4.Id {
+ t.Fatal("invalid order")
+ }
+
+ if r1.Order[2] != o3.Id {
+ t.Fatal("invalid order")
+ }
+
+ if r1.Order[3] != o2a.Id {
+ t.Fatal("invalid order")
+ }
+
+ if len(r1.Posts) != 6 {
+ t.Fatal("wrong size")
+ }
+
+ if r1.Posts[o1.Id].Message != o1.Message {
+ t.Fatal("Missing parent")
+ }
+}
+
func TestPostStoreSearch(t *testing.T) {
Setup()
diff --git a/store/store.go b/store/store.go
index 8dbf12b55..271caa366 100644
--- a/store/store.go
+++ b/store/store.go
@@ -75,6 +75,7 @@ type PostStore interface {
Get(id string) StoreChannel
Delete(postId string, time int64) StoreChannel
GetPosts(channelId string, offset int, limit int) StoreChannel
+ GetPostsSince(channelId string, time int64) StoreChannel
GetEtag(channelId string) StoreChannel
Search(teamId string, userId string, terms string, isHashtagSearch bool) StoreChannel
}
diff --git a/utils/config.go b/utils/config.go
index 46daf203c..36301264c 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -109,16 +109,18 @@ type PrivacySettings struct {
}
type TeamSettings struct {
- MaxUsersPerTeam int
- AllowPublicLink bool
- AllowValetDefault bool
- TermsLink string
- PrivacyLink string
- AboutLink string
- HelpLink string
- ReportProblemLink string
- TourLink string
- DefaultThemeColor string
+ MaxUsersPerTeam int
+ AllowPublicLink bool
+ AllowValetDefault bool
+ TermsLink string
+ PrivacyLink string
+ AboutLink string
+ HelpLink string
+ ReportProblemLink string
+ TourLink string
+ DefaultThemeColor string
+ DisableTeamCreation bool
+ RestrictCreationToDomains string
}
type Config struct {
diff --git a/utils/mail.go b/utils/mail.go
index 783a2de8c..7cb178626 100644
--- a/utils/mail.go
+++ b/utils/mail.go
@@ -12,6 +12,7 @@ import (
"net"
"net/mail"
"net/smtp"
+ "time"
)
func CheckMailSettings() *model.AppError {
@@ -101,6 +102,7 @@ func SendMail(to, subject, body string) *model.AppError {
headers["Subject"] = html.UnescapeString(subject)
headers["MIME-version"] = "1.0"
headers["Content-Type"] = "text/html"
+ headers["Date"] = time.Now().Format(time.RFC1123Z)
message := ""
for k, v := range headers {
diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx
index 525b67b5c..0fa433383 100644
--- a/web/react/components/channel_loader.jsx
+++ b/web/react/components/channel_loader.jsx
@@ -5,63 +5,83 @@
to the server on page load. This is to prevent other React controls from spamming
AsyncClient with requests. */
-var BrowserStore = require('../stores/browser_store.jsx');
var AsyncClient = require('../utils/async_client.jsx');
var SocketStore = require('../stores/socket_store.jsx');
var ChannelStore = require('../stores/channel_store.jsx');
var PostStore = require('../stores/post_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
var Constants = require('../utils/constants.jsx');
+var utils = require('../utils/utils.jsx');
+
module.exports = React.createClass({
componentDidMount: function() {
- /* Start initial aysnc loads */
+ /* Initial aysnc loads */
AsyncClient.getMe();
- AsyncClient.getPosts(true, ChannelStore.getCurrentId(), Constants.POST_CHUNK_SIZE);
+ AsyncClient.getPosts(ChannelStore.getCurrentId());
AsyncClient.getChannels(true, true);
AsyncClient.getChannelExtraInfo(true);
AsyncClient.findTeams();
AsyncClient.getStatuses();
AsyncClient.getMyTeam();
- /* End of async loads */
/* Perform pending post clean-up */
PostStore.clearPendingPosts();
- /* End pending post clean-up */
- /* Start interval functions */
+ /* Set up interval functions */
setInterval(
function pollStatuses() {
AsyncClient.getStatuses();
}, 30000);
- /* End interval functions */
- /* Start device tracking setup */
+ /* Device tracking setup */
var iOS = (/(iPad|iPhone|iPod)/g).test(navigator.userAgent);
if (iOS) {
$('body').addClass('ios');
}
- /* End device tracking setup */
- /* Start window active tracking setup */
+ /* Set up tracking for whether the window is active */
window.isActive = true;
- $(window).focus(function() {
+ $(window).focus(function windowFocus() {
AsyncClient.updateLastViewedAt();
window.isActive = true;
});
- $(window).blur(function() {
+ $(window).blur(function windowBlur() {
window.isActive = false;
});
- /* End window active tracking setup */
/* Start global change listeners setup */
- SocketStore.addChangeListener(this._onSocketChange);
- /* End global change listeners setup */
+ SocketStore.addChangeListener(this.onSocketChange);
+
+ /* Update CSS classes to match user theme */
+ var user = UserStore.getCurrentUser();
+
+ if (user.props && user.props.theme) {
+ utils.changeCss('div.theme', 'background-color:' + user.props.theme + ';');
+ utils.changeCss('.btn.btn-primary', 'background: ' + user.props.theme + ';');
+ utils.changeCss('.modal .modal-header', 'background: ' + user.props.theme + ';');
+ utils.changeCss('.mention', 'background: ' + user.props.theme + ';');
+ utils.changeCss('.mention-link', 'color: ' + user.props.theme + ';');
+ utils.changeCss('@media(max-width: 768px){.search-bar__container', 'background: ' + user.props.theme + ';}');
+ utils.changeCss('.search-item-container:hover', 'background: ' + utils.changeOpacity(user.props.theme, 0.05) + ';');
+ }
+
+ if (user.props.theme !== '#000000' && user.props.theme !== '#585858') {
+ utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, -10) + ';');
+ utils.changeCss('a.theme', 'color:' + user.props.theme + '; fill:' + user.props.theme + '!important;');
+ } else if (user.props.theme === '#000000') {
+ utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, +50) + ';');
+ $('.team__header').addClass('theme--black');
+ } else if (user.props.theme === '#585858') {
+ utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, +10) + ';');
+ $('.team__header').addClass('theme--gray');
+ }
},
- _onSocketChange: function(msg) {
- if (msg && msg.user_id) {
+ onSocketChange: function(msg) {
+ if (msg && msg.user_id && msg.user_id !== UserStore.getCurrentId()) {
UserStore.setStatus(msg.user_id, 'online');
}
},
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index 1de768872..f6e34fda9 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -29,8 +29,6 @@ module.exports = React.createClass({
return;
}
- this.setState({submitting: true, serverError: null});
-
var post = {};
post.filenames = [];
post.message = this.state.messageText;
@@ -57,11 +55,10 @@ module.exports = React.createClass({
PostStore.storePendingPost(post);
PostStore.storeCommentDraft(this.props.rootId, null);
- this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
client.createPost(post, ChannelStore.getCurrent(),
function(data) {
- AsyncClient.getPosts(true, this.props.channelId);
+ AsyncClient.getPosts(this.props.channelId);
var channel = ChannelStore.get(this.props.channelId);
var member = ChannelStore.getMember(this.props.channelId);
@@ -91,6 +88,8 @@ module.exports = React.createClass({
this.setState(state);
}.bind(this)
);
+
+ this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
},
commentMsgKeyPress: function(e) {
if (e.which === 13 && !e.shiftKey && !e.altKey) {
@@ -105,14 +104,17 @@ module.exports = React.createClass({
this.lastTime = t;
}
},
- handleUserInput: function(messageText) {
+ handleUserInput: function(message) {
+ var messageText = utils.truncateText(message);
+ var newPostError = utils.checkMessageLengthError(messageText, this.state.postError, 'Comment length cannot exceed ' + Constants.MAX_POST_LEN + ' characters');
+
var draft = PostStore.getCommentDraft(this.props.rootId);
draft.message = messageText;
PostStore.storeCommentDraft(this.props.rootId, draft);
$('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight);
$('.post-right__scroll').perfectScrollbar('update');
- this.setState({messageText: messageText});
+ this.setState({messageText: messageText, postError: newPostError});
},
handleUploadStart: function(clientIds, channelId) {
var draft = PostStore.getCommentDraft(this.props.rootId);
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index efaa40577..73210c855 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -18,6 +18,7 @@ var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
module.exports = React.createClass({
+ displayName: 'CreatePost',
lastTime: 0,
handleSubmit: function(e) {
e.preventDefault();
@@ -82,7 +83,7 @@ module.exports = React.createClass({
client.createPost(post, channel,
function(data) {
this.resizePostHolder();
- AsyncClient.getPosts(true);
+ AsyncClient.getPosts();
var member = ChannelStore.getMember(channel.id);
member.msg_count = channel.total_msg_count;
@@ -112,8 +113,6 @@ module.exports = React.createClass({
}.bind(this)
);
}
-
- $('.post-list-holder-by-time').perfectScrollbar('update');
},
componentDidUpdate: function() {
this.resizePostHolder();
@@ -131,9 +130,12 @@ module.exports = React.createClass({
this.lastTime = t;
}
},
- handleUserInput: function(messageText) {
+ handleUserInput: function(message) {
+ var messageText = utils.truncateText(message);
+ var newPostError = utils.checkMessageLengthError(messageText, this.state.postError, 'Message length cannot exceed ' + Constants.MAX_POST_LEN + ' characters');
+
this.resizePostHolder();
- this.setState({messageText: messageText});
+ this.setState({messageText: messageText, postError: newPostError});
var draft = PostStore.getCurrentDraft();
draft['message'] = messageText;
diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx
index 1b6a7e162..55d6f509c 100644
--- a/web/react/components/delete_post_modal.jsx
+++ b/web/react/components/delete_post_modal.jsx
@@ -44,7 +44,8 @@ module.exports = React.createClass({
}
}
}
- AsyncClient.getPosts(true, this.state.channel_id);
+ PostStore.removePost(this.state.post_id, this.state.channel_id);
+ AsyncClient.getPosts(this.state.channel_id);
}.bind(this),
function(err) {
AsyncClient.dispatchError(err, "deletePost");
diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx
index 2d865a45d..df692e1bb 100644
--- a/web/react/components/edit_post_modal.jsx
+++ b/web/react/components/edit_post_modal.jsx
@@ -3,6 +3,8 @@
var Client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx');
+var Constants = require('../utils/constants.jsx');
+var utils = require('../utils/utils.jsx');
var Textbox = require('./textbox.jsx');
var BrowserStore = require('../stores/browser_store.jsx');
@@ -25,7 +27,7 @@ module.exports = React.createClass({
Client.updatePost(updatedPost,
function(data) {
- AsyncClient.getPosts(true, this.state.channel_id);
+ AsyncClient.getPosts(this.state.channel_id);
window.scrollTo(0, 0);
}.bind(this),
function(err) {
@@ -37,7 +39,9 @@ module.exports = React.createClass({
$(this.state.refocusId).focus();
},
handleEditInput: function(editText) {
- this.setState({ editText: editText });
+ var editMessage = utils.truncateText(editText);
+ var newError = utils.checkMessageLengthError(editMessage, this.state.error, 'New message length cannot exceed ' + Constants.MAX_POST_LEN + ' characters');
+ this.setState({editText: editMessage, error: newError});
},
handleEditKeyPress: function(e) {
if (e.which == 13 && !e.shiftKey && !e.altKey) {
@@ -53,7 +57,7 @@ module.exports = React.createClass({
var self = this;
$(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
- self.setState({ editText: "", title: "", channel_id: "", post_id: "", comments: 0, refocusId: "" });
+ self.setState({editText: "", title: "", channel_id: "", post_id: "", comments: 0, refocusId: "", error: ''});
});
$(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
@@ -69,7 +73,7 @@ module.exports = React.createClass({
return { editText: "", title: "", post_id: "", channel_id: "", comments: 0, refocusId: "" };
},
render: function() {
- var error = this.state.error ? <div className='form-group has-error'><label className='control-label'>{ this.state.error }</label></div> : null;
+ var error = this.state.error ? <div className='form-group has-error'><br /><label className='control-label'>{ this.state.error }</label></div> : <div className='form-group'><br /></div>;
return (
<div className="modal fade edit-modal" ref="modal" id="edit_post" role="dialog" tabIndex="-1" aria-hidden="true">
diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx
index cc2e37fa8..acc2b51d2 100644
--- a/web/react/components/post.jsx
+++ b/web/react/components/post.jsx
@@ -11,11 +11,12 @@ var ChannelStore = require('../stores/channel_store.jsx');
var client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx');
var ActionTypes = Constants.ActionTypes;
+var utils = require('../utils/utils.jsx');
var PostInfo = require('./post_info.jsx');
module.exports = React.createClass({
- displayName: "Post",
+ displayName: 'Post',
handleCommentClick: function(e) {
e.preventDefault();
@@ -43,7 +44,7 @@ module.exports = React.createClass({
var post = this.props.post;
client.createPost(post, post.channel_id,
function(data) {
- AsyncClient.getPosts(true);
+ AsyncClient.getPosts();
var channel = ChannelStore.get(post.channel_id);
var member = ChannelStore.getMember(post.channel_id);
@@ -67,6 +68,13 @@ module.exports = React.createClass({
PostStore.updatePendingPost(post);
this.forceUpdate();
},
+ shouldComponentUpdate: function(nextProps) {
+ if (!utils.areStatesEqual(nextProps.post, this.props.post)) {
+ return true;
+ }
+
+ return false;
+ },
getInitialState: function() {
return { };
},
@@ -90,16 +98,16 @@ module.exports = React.createClass({
var error = this.state.error ? <div className='form-group has-error'><label className='control-label'>{ this.state.error }</label></div> : null;
- var rootUser = this.props.sameRoot ? "same--root" : "other--root";
+ var rootUser = this.props.sameRoot ? 'same--root' : 'other--root';
- var postType = "";
- if (type != "Post"){
- postType = "post--comment";
+ var postType = '';
+ if (type != 'Post'){
+ postType = 'post--comment';
}
- var currentUserCss = "";
+ var currentUserCss = '';
if (UserStore.getCurrentId() === post.user_id) {
- currentUserCss = "current--user";
+ currentUserCss = 'current--user';
}
var userProfile = UserStore.getProfile(post.user_id);
@@ -109,18 +117,23 @@ module.exports = React.createClass({
timestamp = userProfile.update_at;
}
+ var sameUserClass = '';
+ if (this.props.sameUser) {
+ sameUserClass = 'same--user';
+ }
+
return (
<div>
- <div id={post.id} className={"post " + this.props.sameUser + " " + rootUser + " " + postType + " " + currentUserCss}>
+ <div id={post.id} className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss}>
{ !this.props.hideProfilePic ?
- <div className="post-profile-img__container">
- <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" />
+ <div className='post-profile-img__container'>
+ <img className='post-profile-img' src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp} height='36' width='36' />
</div>
: null }
- <div className="post__content">
- <PostHeader ref="header" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} />
+ <div className='post__content'>
+ <PostHeader ref='header' post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} />
<PostBody post={post} sameRoot={this.props.sameRoot} parentPost={parentPost} posts={posts} handleCommentClick={this.handleCommentClick} retryPost={this.retryPost} />
- <PostInfo ref="info" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} allowReply="true" />
+ <PostInfo ref='info' post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} allowReply='true' />
</div>
</div>
</div>
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
index 7748f5c2a..865a22dbd 100644
--- a/web/react/components/post_list.jsx
+++ b/web/react/components/post_list.jsx
@@ -15,124 +15,116 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
-function getStateFromStores() {
- var channel = ChannelStore.getCurrent();
-
- if (channel == null) {
- channel = {};
+export default class PostList extends React.Component {
+ constructor() {
+ super();
+
+ this.gotMorePosts = false;
+ this.scrolled = false;
+ this.prevScrollTop = 0;
+ this.seenNewMessages = false;
+ this.isUserScroll = true;
+ this.userHasSeenNew = false;
+
+ this.onChange = this.onChange.bind(this);
+ this.onTimeChange = this.onTimeChange.bind(this);
+ this.onSocketChange = this.onSocketChange.bind(this);
+ this.createChannelIntroMessage = this.createChannelIntroMessage.bind(this);
+ this.loadMorePosts = this.loadMorePosts.bind(this);
+
+ this.state = this.getStateFromStores();
+ this.state.numToDisplay = Constants.POST_CHUNK_SIZE;
}
+ getStateFromStores() {
+ var channel = ChannelStore.getCurrent();
- var postList = PostStore.getCurrentPosts();
- var deletedPosts = PostStore.getUnseenDeletedPosts(channel.id);
-
- if (deletedPosts && Object.keys(deletedPosts).length > 0) {
- for (var pid in deletedPosts) {
- postList.posts[pid] = deletedPosts[pid];
- postList.order.unshift(pid);
+ if (channel == null) {
+ channel = {};
}
- postList.order.sort(function postSort(a, b) {
- if (postList.posts[a].create_at > postList.posts[b].create_at) {
- return -1;
- }
- if (postList.posts[a].create_at < postList.posts[b].create_at) {
- return 1;
- }
- return 0;
- });
- }
+ var postList = PostStore.getCurrentPosts();
- var pendingPostList = PostStore.getPendingPosts(channel.id);
+ if (postList != null) {
+ var deletedPosts = PostStore.getUnseenDeletedPosts(channel.id);
- if (pendingPostList) {
- postList.order = pendingPostList.order.concat(postList.order);
- for (var ppid in pendingPostList.posts) {
- postList.posts[ppid] = pendingPostList.posts[ppid];
- }
- }
+ if (deletedPosts && Object.keys(deletedPosts).length > 0) {
+ for (var pid in deletedPosts) {
+ postList.posts[pid] = deletedPosts[pid];
+ postList.order.unshift(pid);
+ }
- return {
- postList: postList,
- channel: channel
- };
-}
+ postList.order.sort(function postSort(a, b) {
+ if (postList.posts[a].create_at > postList.posts[b].create_at) {
+ return -1;
+ }
+ if (postList.posts[a].create_at < postList.posts[b].create_at) {
+ return 1;
+ }
+ return 0;
+ });
+ }
+
+ var pendingPostList = PostStore.getPendingPosts(channel.id);
-module.exports = React.createClass({
- displayName: 'PostList',
- scrollPosition: 0,
- preventScrollTrigger: false,
- gotMorePosts: false,
- oldScrollHeight: 0,
- oldZoom: 0,
- scrolledToNew: false,
- componentDidMount: function() {
- var user = UserStore.getCurrentUser();
- if (user.props && user.props.theme) {
- utils.changeCss('div.theme', 'background-color:' + user.props.theme + ';');
- utils.changeCss('.btn.btn-primary', 'background: ' + user.props.theme + ';');
- utils.changeCss('.modal .modal-header', 'background: ' + user.props.theme + ';');
- utils.changeCss('.mention', 'background: ' + user.props.theme + ';');
- utils.changeCss('.mention-link', 'color: ' + user.props.theme + ';');
- utils.changeCss('@media(max-width: 768px){.search-bar__container', 'background: ' + user.props.theme + ';}');
- utils.changeCss('.search-item-container:hover', 'background: ' + utils.changeOpacity(user.props.theme, 0.05) + ';');
- utils.changeCss('.nav-pills__unread-indicator', 'background: ' + utils.changeOpacity(user.props.theme, 0.05) + ';');
+ if (pendingPostList) {
+ postList.order = pendingPostList.order.concat(postList.order);
+ for (var ppid in pendingPostList.posts) {
+ postList.posts[ppid] = pendingPostList.posts[ppid];
+ }
+ }
}
- if (user.props.theme !== '#000000' && user.props.theme !== '#585858') {
- utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, -10) + ';');
- utils.changeCss('a.theme', 'color:' + user.props.theme + '; fill:' + user.props.theme + '!important;');
- } else if (user.props.theme === '#000000') {
- utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, +50) + ';');
- $('.team__header').addClass('theme--black');
- } else if (user.props.theme === '#585858') {
- utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + utils.changeColor(user.props.theme, +10) + ';');
- $('.team__header').addClass('theme--gray');
+ var lastViewed = Number.MAX_VALUE;
+
+ if (ChannelStore.getCurrentMember() != null) {
+ lastViewed = ChannelStore.getCurrentMember().last_viewed_at;
}
+ return {
+ postList: postList,
+ channel: channel,
+ lastViewed: lastViewed
+ };
+ }
+ componentDidMount() {
PostStore.addChangeListener(this.onChange);
ChannelStore.addChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onTimeChange);
SocketStore.addChangeListener(this.onSocketChange);
- $('.post-list-holder-by-time').perfectScrollbar();
-
- this.resize();
-
- var postHolder = $('.post-list-holder-by-time')[0];
- this.scrollPosition = $(postHolder).scrollTop() + $(postHolder).innerHeight();
- this.oldScrollHeight = postHolder.scrollHeight;
- this.oldZoom = (window.outerWidth - 8) / window.innerWidth;
+ var postHolder = $('.post-list-holder-by-time');
$('.modal').on('show.bs.modal', function onShow() {
$('.modal-body').css('overflow-y', 'auto');
$('.modal-body').css('max-height', $(window).height() * 0.7);
});
- var self = this;
$(window).resize(function resize() {
- $(postHolder).perfectScrollbar('update');
-
- // this only kind of works, detecting zoom in browsers is a nightmare
- var newZoom = (window.outerWidth - 8) / window.innerWidth;
+ if ($('#create_post').length > 0) {
+ var height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50;
+ postHolder.css('height', height + 'px');
+ }
- if (self.scrollPosition >= postHolder.scrollHeight || (self.oldScrollHeight !== postHolder.scrollHeight && self.scrollPosition >= self.oldScrollHeight) || self.oldZoom !== newZoom) {
- self.resize();
+ if (!this.scrolled) {
+ this.scrollToBottom();
}
+ }.bind(this));
- self.oldZoom = newZoom;
+ postHolder.scroll(function scroll() {
+ var position = postHolder.scrollTop() + postHolder.height() + 14;
+ var bottom = postHolder[0].scrollHeight;
- if ($('#create_post').length > 0) {
- var height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50;
- $('.post-list-holder-by-time').css('height', height + 'px');
+ if (position >= bottom) {
+ this.scrolled = false;
+ } else {
+ this.scrolled = true;
}
- });
- $(postHolder).scroll(function scroll() {
- if (!self.preventScrollTrigger) {
- self.scrollPosition = $(postHolder).scrollTop() + $(postHolder).innerHeight();
+ if (this.isUserScroll) {
+ this.userHasSeenNew = true;
}
- self.preventScrollTrigger = false;
- });
+ this.isUserScroll = true;
+ }.bind(this));
$('body').on('click.userpopover', function popOver(e) {
if ($(e.target).attr('data-toggle') !== 'popover' &&
@@ -163,76 +155,101 @@ module.exports = React.createClass({
$(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--comment');
}
});
- },
- componentDidUpdate: function() {
- this.resize();
- var postHolder = $('.post-list-holder-by-time')[0];
- this.scrollPosition = $(postHolder).scrollTop() + $(postHolder).innerHeight();
- this.oldScrollHeight = postHolder.scrollHeight;
+
+ this.scrollToBottom();
+ setTimeout(this.scrollToBottom, 100);
+ }
+ componentDidUpdate(prevProps, prevState) {
$('.post-list__content div .post').removeClass('post--last');
$('.post-list__content div:last-child .post').addClass('post--last');
- },
- componentWillUnmount: function() {
+
+ if (this.state.postList == null || prevState.postList == null) {
+ this.scrollToBottom();
+ return;
+ }
+
+ var order = this.state.postList.order || [];
+ var posts = this.state.postList.posts || {};
+ var oldOrder = prevState.postList.order || [];
+ var oldPosts = prevState.postList.posts || {};
+ var userId = UserStore.getCurrentId();
+ var firstPost = posts[order[0]] || {};
+ var isNewPost = oldOrder.indexOf(order[0]) === -1;
+
+ if (this.state.channel.id !== prevState.channel.id) {
+ this.scrollToBottom();
+ } else if (oldOrder.length === 0) {
+ this.scrollToBottom();
+
+ // the user is scrolled to the bottom
+ } else if (!this.scrolled) {
+ this.scrollToBottom();
+
+ // there's a new post and
+ // it's by the user and not a comment
+ } else if (isNewPost &&
+ userId === firstPost.user_id &&
+ !utils.isComment(firstPost)) {
+ this.state.lastViewed = utils.getTimestamp();
+ this.scrollToBottom(true);
+
+ // the user clicked 'load more messages'
+ } else if (this.gotMorePosts) {
+ var lastPost = oldPosts[oldOrder[prevState.numToDisplay]];
+ $('#' + lastPost.id)[0].scrollIntoView();
+ } else {
+ this.scrollTo(this.prevScrollTop);
+ }
+ }
+ componentWillUpdate() {
+ var postHolder = $('.post-list-holder-by-time');
+ this.prevScrollTop = postHolder.scrollTop();
+ }
+ componentWillUnmount() {
PostStore.removeChangeListener(this.onChange);
ChannelStore.removeChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onTimeChange);
SocketStore.removeChangeListener(this.onSocketChange);
$('body').off('click.userpopover');
$('.modal').off('show.bs.modal');
- },
- resize: function() {
- var postHolder = $('.post-list-holder-by-time')[0];
- this.preventScrollTrigger = true;
- if (this.gotMorePosts) {
- this.gotMorePosts = false;
- $(postHolder).scrollTop($(postHolder).scrollTop() + (postHolder.scrollHeight - this.oldScrollHeight));
- } else if ($('#new_message')[0] && !this.scrolledToNew) {
- $(postHolder).scrollTop($(postHolder).scrollTop() + $('#new_message').offset().top - 63);
- this.scrolledToNew = true;
+ }
+ scrollTo(val) {
+ this.isUserScroll = false;
+ var postHolder = $('.post-list-holder-by-time');
+ postHolder[0].scrollTop = val;
+ }
+ scrollToBottom(force) {
+ this.isUserScroll = false;
+ var postHolder = $('.post-list-holder-by-time');
+ if ($('#new_message')[0] && !this.userHasSeenNew && !force) {
+ $('#new_message')[0].scrollIntoView();
} else {
- $(postHolder).scrollTop(postHolder.scrollHeight);
+ postHolder.addClass('hide-scroll');
+ postHolder[0].scrollTop = postHolder[0].scrollHeight;
+ postHolder.removeClass('hide-scroll');
}
- $(postHolder).perfectScrollbar('update');
- },
- onChange: function() {
- var newState = getStateFromStores();
+ }
+ onChange() {
+ var newState = this.getStateFromStores();
if (!utils.areStatesEqual(newState, this.state)) {
- if (this.state.postList && this.state.postList.order) {
- if (this.state.channel.id === newState.channel.id && this.state.postList.order.length !== newState.postList.order.length && newState.postList.order.length > Constants.POST_CHUNK_SIZE) {
- this.gotMorePosts = true;
- }
- }
if (this.state.channel.id !== newState.channel.id) {
PostStore.clearUnseenDeletedPosts(this.state.channel.id);
- this.scrolledToNew = false;
+ this.userHasSeenNew = false;
+ newState.numToDisplay = Constants.POST_CHUNK_SIZE;
+ } else {
+ newState.lastViewed = this.state.lastViewed;
}
+
this.setState(newState);
}
- },
- onSocketChange: function(msg) {
+ }
+ onSocketChange(msg) {
var postList;
var post;
- if (msg.action === 'posted') {
+ if (msg.action === 'posted' || msg.action === 'post_edited') {
post = JSON.parse(msg.props.post);
PostStore.storePost(post);
- } else if (msg.action === 'post_edited') {
- if (this.state.channel.id === msg.channel_id) {
- postList = this.state.postList;
- if (!(msg.props.post_id in postList.posts)) {
- return;
- }
-
- post = postList.posts[msg.props.post_id];
- post.message = msg.props.message;
-
- postList.posts[post.id] = post;
- this.setState({postList: postList});
-
- PostStore.storePosts(msg.channel_id, postList);
- } else {
- AsyncClient.getPosts(true, msg.channel_id);
- }
} else if (msg.action === 'post_deleted') {
var activeRoot = $(document.activeElement).closest('.comment-create-body')[0];
var activeRootPostId = '';
@@ -244,16 +261,8 @@ module.exports = React.createClass({
postList = this.state.postList;
PostStore.storeUnseenDeletedPost(post);
-
- if (postList.posts[post.id]) {
- delete postList.posts[post.id];
- var index = postList.order.indexOf(post.id);
- if (index > -1) {
- postList.order.splice(index, 1);
- }
-
- PostStore.storePosts(msg.channel_id, postList);
- }
+ PostStore.removePost(post, true);
+ PostStore.emitChange();
if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() !== msg.user_id) {
$('#post_deleted').modal('show');
@@ -261,8 +270,8 @@ module.exports = React.createClass({
} else if (msg.action === 'new_user') {
AsyncClient.getProfiles();
}
- },
- onTimeChange: function() {
+ }
+ onTimeChange() {
if (!this.state.postList) {
return;
}
@@ -273,271 +282,331 @@ module.exports = React.createClass({
}
this.refs[id].forceUpdateInfo();
}
- },
- getMorePosts: function(e) {
- e.preventDefault();
-
- if (!this.state.postList) {
- return;
- }
-
- var posts = this.state.postList.posts;
- var order = this.state.postList.order;
- var channelId = this.state.channel.id;
-
- $(this.refs.loadmore.getDOMNode()).text('Retrieving more messages...');
-
- var self = this;
- var currentPos = $('.post-list').scrollTop;
-
- Client.getPosts(
- channelId,
- order.length,
- Constants.POST_CHUNK_SIZE,
- function success(data) {
- $(self.refs.loadmore.getDOMNode()).text('Load more messages');
-
- if (!data) {
- return;
- }
+ }
+ createDMIntroMessage(channel) {
+ var teammate = utils.getDirectTeammate(channel.id);
- if (data.order.length === 0) {
- return;
- }
+ if (teammate) {
+ var teammateName = teammate.username;
+ if (teammate.nickname.length > 0) {
+ teammateName = teammate.nickname;
+ }
- var postList = {};
- postList.posts = $.extend(posts, data.posts);
- postList.order = order.concat(data.order);
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_POSTS,
- id: channelId,
- postList: postList
- });
-
- Client.getProfiles();
- $('.post-list').scrollTop(currentPos);
- },
- function fail(err) {
- $(self.refs.loadmore.getDOMNode()).text('Load more messages');
- AsyncClient.dispatchError(err, 'getPosts');
- }
+ return (
+ <div className='channel-intro'>
+ <div className='post-profile-img__container channel-intro-img'>
+ <img
+ className='post-profile-img'
+ src={'/api/v1/users/' + teammate.id + '/image?time=' + teammate.update_at}
+ height='50'
+ width='50'
+ />
+ </div>
+ <div className='channel-intro-profile'>
+ <strong><UserProfile userId={teammate.id} /></strong>
+ </div>
+ <p className='channel-intro-text'>
+ {'This is the start of your private message history with ' + teammateName + '.'}<br/>
+ {'Private messages and files shared here are not shown to people outside this area.'}
+ </p>
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#edit_channel'
+ data-desc={channel.description}
+ data-title={channel.display_name}
+ data-channelid={channel.id}
+ >
+ <i className='fa fa-pencil'></i>Set a description
+ </a>
+ </div>
);
- },
- getInitialState: function() {
- return getStateFromStores();
- },
- render: function() {
- var order = [];
- var posts;
+ }
- var lastViewed = Number.MAX_VALUE;
+ return (
+ <div className='channel-intro'>
+ <p className='channel-intro-text'>{'This is the start of your private message history with this ' + strings.Team + 'mate. Private messages and files shared here are not shown to people outside this area.'}</p>
+ </div>
+ );
+ }
+ createChannelIntroMessage(channel) {
+ if (channel.type === 'D') {
+ return this.createDMIntroMessage(channel);
+ } else if (ChannelStore.isDefault(channel)) {
+ return this.createDefaultIntroMessage(channel);
+ } else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
+ return this.createOffTopicIntroMessage(channel);
+ } else if (channel.type === 'O' || channel.type === 'P') {
+ return this.createStandardIntroMessage(channel);
+ }
+ }
+ createDefaultIntroMessage(channel) {
+ return (
+ <div className='channel-intro'>
+ <h4 className='channel-intro__title'>Beginning of {channel.display_name}</h4>
+ <p className='channel-intro__content'>
+ Welcome to {channel.display_name}!
+ <br/><br/>
+ This is the first channel {strings.Team}mates see when they
+ <br/>
+ sign up - use it for posting updates everyone needs to know.
+ <br/><br/>
+ To create a new channel or join an existing one, go to
+ <br/>
+ the Left Hand Sidebar under “Channels” and click “More…”.
+ <br/>
+ </p>
+ </div>
+ );
+ }
+ createOffTopicIntroMessage(channel) {
+ return (
+ <div className='channel-intro'>
+ <h4 className='channel-intro__title'>Beginning of {channel.display_name}</h4>
+ <p className='channel-intro__content'>
+ {'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'}
+ <br/>
+ </p>
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#edit_channel'
+ data-desc={channel.description}
+ data-title={channel.display_name}
+ data-channelid={channel.id}
+ >
+ <i className='fa fa-pencil'></i>Set a description
+ </a>
+ </div>
+ );
+ }
+ getChannelCreator(channel) {
+ if (channel.creator_id.length > 0) {
+ var creator = UserStore.getProfile(channel.creator_id);
+ if (creator) {
+ return creator.username;
+ }
+ }
- if (ChannelStore.getCurrentMember() != null) {
- lastViewed = ChannelStore.getCurrentMember().last_viewed_at;
+ var members = ChannelStore.getCurrentExtraInfo().members;
+ for (var i = 0; i < members.length; i++) {
+ if (members[i].roles.indexOf('admin') > -1) {
+ return members[i].username;
+ }
+ }
+ }
+ createStandardIntroMessage(channel) {
+ var uiName = channel.display_name;
+ var creatorName = '';
+
+ var uiType;
+ var memberMessage;
+ if (channel.type === 'P') {
+ uiType = 'private group';
+ memberMessage = ' Only invited members can see this private group.';
+ } else {
+ uiType = 'channel';
+ memberMessage = ' Any member can join and read this channel.';
}
- if (this.state.postList != null) {
- posts = this.state.postList.posts;
- order = this.state.postList.order;
+ var createMessage;
+ if (creatorName !== '') {
+ createMessage = (<span>This is the start of the <strong>{uiName}</strong> {uiType}, created by <strong>{creatorName}</strong> on <strong>{utils.displayDate(channel.create_at)}</strong></span>);
+ } else {
+ createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + utils.displayDate(channel.create_at) + '.';
}
+ return (
+ <div className='channel-intro'>
+ <h4 className='channel-intro__title'>Beginning of {uiName}</h4>
+ <p className='channel-intro__content'>
+ {createMessage}
+ {memberMessage}
+ <br/>
+ </p>
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#edit_channel'
+ data-desc={channel.description}
+ data-title={channel.display_name}
+ data-channelid={channel.id}
+ >
+ <i className='fa fa-pencil'></i>Set a description
+ </a>
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#channel_invite'
+ >
+ <i className='fa fa-user-plus'></i>Invite others to this {uiType}
+ </a>
+ </div>
+ );
+ }
+ createPosts(posts, order) {
+ var postCtls = [];
+ var previousPostDay = new Date(0);
+ var userId = UserStore.getCurrentId();
+
var renderedLastViewed = false;
- var userId = '';
- if (UserStore.getCurrentId()) {
- userId = UserStore.getCurrentId();
- } else {
- return <div/>;
+ var numToDisplay = this.state.numToDisplay;
+ if (order.length - 1 < numToDisplay) {
+ numToDisplay = order.length - 1;
}
- var channel = this.state.channel;
-
- var moreMessages = <p className='beginning-messages-text'>Beginning of Channel</p>;
+ for (var i = numToDisplay; i >= 0; i--) {
+ var post = posts[order[i]];
+ var parentPost = posts[post.parent_id];
- var userStyle = {color: UserStore.getCurrentUser().props.theme};
+ var sameUser = false;
+ var sameRoot = false;
+ var hideProfilePic = false;
+ var prevPost = posts[order[i + 1]];
- if (channel != null) {
- if (order.length > 0 && order.length % Constants.POST_CHUNK_SIZE === 0) {
- moreMessages = <a ref='loadmore' className='more-messages-text theme' href='#' onClick={this.getMorePosts}>Load more messages</a>;
- } else if (channel.type === 'D') {
- var teammate = utils.getDirectTeammate(channel.id);
-
- if (teammate) {
- var teammateName = teammate.username;
- if (teammate.nickname.length > 0) {
- teammateName = teammate.nickname;
- }
+ if (prevPost) {
+ sameUser = prevPost.user_id === post.user_id && post.create_at - prevPost.create_at <= 1000 * 60 * 5;
- moreMessages = (
- <div className='channel-intro'>
- <div className='post-profile-img__container channel-intro-img'>
- <img className='post-profile-img' src={'/api/v1/users/' + teammate.id + '/image?time=' + teammate.update_at} height='50' width='50' />
- </div>
- <div className='channel-intro-profile'>
- <strong><UserProfile userId={teammate.id} /></strong>
- </div>
- <p className='channel-intro-text'>
- This is the start of your private message history with <strong>{teammateName}</strong>.<br/>
- Private messages and files shared here are not shown to people outside this area.
- </p>
- <a className='intro-links' href='#' style={userStyle} data-toggle='modal' data-target='#edit_channel' data-desc={channel.description} data-title={channel.display_name} data-channelid={channel.id}><i className='fa fa-pencil'></i>Set a description</a>
- </div>
- );
- } else {
- moreMessages = (
- <div className='channel-intro'>
- <p className='channel-intro-text'>{'This is the start of your private message history with this ' + strings.Team + 'mate. Private messages and files shared here are not shown to people outside this area.'}</p>
- </div>
- );
- }
- } else if (channel.type === 'P' || channel.type === 'O') {
- var uiName = channel.display_name;
- var creatorName = '';
-
- if (channel.creator_id.length > 0) {
- var creator = UserStore.getProfile(channel.creator_id);
- if (creator) {
- creatorName = creator.username;
- }
- }
+ sameRoot = utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id);
- if (creatorName === '') {
- var members = ChannelStore.getCurrentExtraInfo().members;
- for (var i = 0; i < members.length; i++) {
- if (members[i].roles.indexOf('admin') > -1) {
- creatorName = members[i].username;
- break;
- }
- }
- }
+ // we only hide the profile pic if the previous post is not a comment, the current post is not a comment, and the previous post was made by the same user as the current post
+ hideProfilePic = (prevPost.user_id === post.user_id) && !utils.isComment(prevPost) && !utils.isComment(post);
+ }
- if (ChannelStore.isDefault(channel)) {
- moreMessages = (
- <div className='channel-intro'>
- <h4 className='channel-intro__title'>Beginning of {uiName}</h4>
- <p className='channel-intro__content'>
- Welcome to <strong>{uiName}</strong>!
- <br/><br/>
- This is the first channel {strings.Team}mates see when they
- <br/>
- sign up - use it for posting updates everyone needs to know.
- <br/><br/>
- To create a new channel or join an existing one, go to
- <br/>
- the Left Hand Sidebar under “Channels” and click “More…”.
- <br/>
- </p>
- </div>
- );
- } else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
- moreMessages = (
- <div className='channel-intro'>
- <h4 className='channel-intro__title'>Beginning of {uiName}</h4>
- <p className='channel-intro__content'>
- This is the start of <strong>{uiName}</strong>, a channel for non-work-related conversations.
- <br/>
- </p>
- <a className='intro-links' href='#' style={userStyle} data-toggle='modal' data-target='#edit_channel' data-desc={channel.description} data-title={uiName} data-channelid={channel.id}><i className='fa fa-pencil'></i>Set a description</a>
- </div>
- );
- } else {
- var uiType;
- var memberMessage;
- if (channel.type === 'P') {
- uiType = 'private group';
- memberMessage = ' Only invited members can see this private group.';
- } else {
- uiType = 'channel';
- memberMessage = ' Any member can join and read this channel.';
- }
+ // check if it's the last comment in a consecutive string of comments on the same post
+ // it is the last comment if it is last post in the channel or the next post has a different root post
+ var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id);
+
+ var postCtl = (
+ <Post
+ key={post.id}
+ ref={post.id}
+ sameUser={sameUser}
+ sameRoot={sameRoot}
+ post={post}
+ parentPost={parentPost}
+ posts={posts}
+ hideProfilePic={hideProfilePic}
+ isLastComment={isLastComment}
+ />
+ );
- var createMessage;
- if (creatorName !== '') {
- createMessage = (<span>This is the start of the <strong>{uiName}</strong> {uiType}, created by <strong>{creatorName}</strong> on <strong>{utils.displayDate(channel.create_at)}</strong></span>);
- } else {
- createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + utils.displayDate(channel.create_at) + '.';
- }
+ let currentPostDay = utils.getDateForUnixTicks(post.create_at);
+ if (currentPostDay.toDateString() !== previousPostDay.toDateString()) {
+ postCtls.push(
+ <div
+ key={currentPostDay.toDateString()}
+ className='date-separator'
+ >
+ <hr className='separator__hr' />
+ <div className='separator__text'>{currentPostDay.toDateString()}</div>
+ </div>
+ );
+ }
- moreMessages = (
- <div className='channel-intro'>
- <h4 className='channel-intro__title'>Beginning of {uiName}</h4>
- <p className='channel-intro__content'>
- {createMessage}
- {memberMessage}
- <br/>
- </p>
- <a className='intro-links' href='#' style={userStyle} data-toggle='modal' data-target='#edit_channel' data-desc={channel.description} data-title={channel.display_name} data-channelid={channel.id}><i className='fa fa-pencil'></i>Set a description</a>
- <a className='intro-links' href='#' style={userStyle} data-toggle='modal' data-target='#channel_invite'><i className='fa fa-user-plus'></i>Invite others to this {uiType}</a>
- </div>
- );
- }
+ if (post.user_id !== userId && post.create_at > this.state.lastViewed && !renderedLastViewed) {
+ renderedLastViewed = true;
+ postCtls.push(
+ <div
+ id='new_message'
+ key='unviewed'
+ className='new-separator'
+ >
+ <hr
+ className='separator__hr'
+ />
+ <div className='separator__text'>New Messages</div>
+ </div>
+ );
}
+ postCtls.push(postCtl);
+ previousPostDay = currentPostDay;
}
- var postCtls = [];
+ return postCtls;
+ }
+ loadMorePosts() {
+ if (this.state.postList == null) {
+ return;
+ }
- if (posts) {
- var previousPostDay = new Date(0);
- var currentPostDay;
-
- for (var i = order.length - 1; i >= 0; i--) {
- var post = posts[order[i]];
- var parentPost = null;
- if (post.parent_id) {
- parentPost = posts[post.parent_id];
- }
+ var posts = this.state.postList.posts;
+ var order = this.state.postList.order;
+ var channelId = this.state.channel.id;
- var sameUser = '';
- var sameRoot = false;
- var hideProfilePic = false;
- var prevPost;
- if (i < order.length - 1) {
- prevPost = posts[order[i + 1]];
- }
+ $(this.refs.loadmore.getDOMNode()).text('Retrieving more messages...');
- if (prevPost) {
- if ((prevPost.user_id === post.user_id) && (post.create_at - prevPost.create_at <= 1000 * 60 * 5)) {
- sameUser = 'same--user';
- }
- sameRoot = utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id);
+ Client.getPostsPage(
+ channelId,
+ order.length,
+ Constants.POST_CHUNK_SIZE,
+ function success(data) {
+ $(this.refs.loadmore.getDOMNode()).text('Load more messages');
+ this.gotMorePosts = true;
+ this.setState({numToDisplay: this.state.numToDisplay + Constants.POST_CHUNK_SIZE});
- // we only hide the profile pic if the previous post is not a comment, the current post is not a comment, and the previous post was made by the same user as the current post
- hideProfilePic = (prevPost.user_id === post.user_id) && !utils.isComment(prevPost) && !utils.isComment(post);
+ if (!data) {
+ return;
}
- // check if it's the last comment in a consecutive string of comments on the same post
- // it is the last comment if it is last post in the channel or the next post has a different root post
- var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id);
+ if (data.order.length === 0) {
+ return;
+ }
- var postCtl = (
- <Post ref={post.id} sameUser={sameUser} sameRoot={sameRoot} post={post} parentPost={parentPost} key={post.id}
- posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment}
- />
- );
+ var postList = {};
+ postList.posts = $.extend(posts, data.posts);
+ postList.order = order.concat(data.order);
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POSTS,
+ id: channelId,
+ post_list: postList
+ });
+
+ Client.getProfiles();
+ }.bind(this),
+ function fail(err) {
+ $(this.refs.loadmore.getDOMNode()).text('Load more messages');
+ AsyncClient.dispatchError(err, 'getPosts');
+ }.bind(this)
+ );
+ }
+ render() {
+ var order = [];
+ var posts;
+ var channel = this.state.channel;
- currentPostDay = utils.getDateForUnixTicks(post.create_at);
- if (currentPostDay.toDateString() !== previousPostDay.toDateString()) {
- postCtls.push(
- <div key={currentPostDay.toDateString()} className='date-separator'>
- <hr className='separator__hr' />
- <div className='separator__text'>{currentPostDay.toDateString()}</div>
- </div>
- );
- }
+ if (this.state.postList != null) {
+ posts = this.state.postList.posts;
+ order = this.state.postList.order;
+ }
- if (post.user_id !== userId && post.create_at > lastViewed && !renderedLastViewed) {
- renderedLastViewed = true;
- postCtls.push(
- <div key='unviewed' className='new-separator'>
- <hr id='new_message' className='separator__hr' />
- <div className='separator__text'>New Messages</div>
- </div>
- );
- }
- postCtls.push(postCtl);
- previousPostDay = currentPostDay;
+ var moreMessages = <p className='beginning-messages-text'>Beginning of Channel</p>;
+ if (channel != null) {
+ if (order.length > this.state.numToDisplay) {
+ moreMessages = (
+ <a
+ ref='loadmore'
+ className='more-messages-text theme'
+ href='#'
+ onClick={this.loadMorePosts}
+ >
+ Load more messages
+ </a>
+ );
+ } else {
+ moreMessages = this.createChannelIntroMessage(channel);
}
+ }
+
+ var postCtls = [];
+ if (posts) {
+ postCtls = this.createPosts(posts, order);
} else {
postCtls.push(<LoadingScreen position='absolute' />);
}
@@ -553,4 +622,4 @@ module.exports = React.createClass({
</div>
);
}
-});
+}
diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx
deleted file mode 100644
index ac4c8a6d7..000000000
--- a/web/react/components/post_right.jsx
+++ /dev/null
@@ -1,404 +0,0 @@
-// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-var PostStore = require('../stores/post_store.jsx');
-var ChannelStore = require('../stores/channel_store.jsx');
-var UserProfile = require('./user_profile.jsx');
-var UserStore = require('../stores/user_store.jsx');
-var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
-var utils = require('../utils/utils.jsx');
-var SearchBox = require('./search_bar.jsx');
-var CreateComment = require('./create_comment.jsx');
-var Constants = require('../utils/constants.jsx');
-var FileAttachmentList = require('./file_attachment_list.jsx');
-var FileUploadOverlay = require('./file_upload_overlay.jsx');
-var client = require('../utils/client.jsx');
-var AsyncClient = require('../utils/async_client.jsx');
-var ActionTypes = Constants.ActionTypes;
-
-RhsHeaderPost = React.createClass({
- handleClose: function(e) {
- e.preventDefault();
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_SEARCH,
- results: null
- });
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_POST_SELECTED,
- results: null
- });
- },
- handleBack: function(e) {
- e.preventDefault();
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_SEARCH_TERM,
- term: this.props.fromSearch,
- do_search: true,
- is_mention_search: this.props.isMentionSearch
- });
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_POST_SELECTED,
- results: null
- });
- },
- render: function() {
- var back;
- if (this.props.fromSearch) {
- back = <a href='#' onClick={this.handleBack} className='sidebar--right__back'><i className='fa fa-chevron-left'></i></a>;
- }
-
- return (
- <div className='sidebar--right__header'>
- <span className='sidebar--right__title'>{back}Message Details</span>
- <button type='button' className='sidebar--right__close' aria-label='Close' onClick={this.handleClose}></button>
- </div>
- );
- }
-});
-
-RootPost = React.createClass({
- render: function() {
- var post = this.props.post;
- var message = utils.textToJsx(post.message);
- var isOwner = UserStore.getCurrentId() === post.user_id;
- var timestamp = UserStore.getProfile(post.user_id).update_at;
- var channel = ChannelStore.get(post.channel_id);
-
- var type = 'Post';
- if (post.root_id.length > 0) {
- type = 'Comment';
- }
-
- var currentUserCss = '';
- if (UserStore.getCurrentId() === post.user_id) {
- currentUserCss = 'current--user';
- }
-
- var channelName;
- if (channel) {
- if (channel.type === 'D') {
- channelName = 'Private Message';
- } else {
- channelName = channel.display_name;
- }
- }
-
- var ownerOptions;
- if (isOwner) {
- ownerOptions = (
- <div>
- <a href='#' className='dropdown-toggle theme' type='button' data-toggle='dropdown' aria-expanded='false' />
- <ul className='dropdown-menu' role='menu'>
- <li role='presentation'><a href='#' role='menuitem' data-toggle='modal' data-target='#edit_post' data-refoucsid='#reply_textbox' data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li>
- <li role='presentation'><a href='#' role='menuitem' data-toggle='modal' data-target='#delete_post' data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={this.props.commentCount}>Delete</a></li>
- </ul>
- </div>
- );
- }
-
- var fileAttachment;
- if (post.filenames && post.filenames.length > 0) {
- fileAttachment = (
- <FileAttachmentList
- filenames={post.filenames}
- modalId={'rhs_view_image_modal_' + post.id}
- channelId={post.channel_id}
- userId={post.user_id} />
- );
- }
-
- return (
- <div className={'post post--root ' + currentUserCss}>
- <div className='post-right-channel__name'>{ channelName }</div>
- <div className='post-profile-img__container'>
- <img className='post-profile-img' src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp} height='36' width='36' />
- </div>
- <div className='post__content'>
- <ul className='post-header'>
- <li className='post-header-col'><strong><UserProfile userId={post.user_id} /></strong></li>
- <li className='post-header-col'><time className='post-right-root-time'>{utils.displayCommentDateTime(post.create_at)}</time></li>
- <li className='post-header-col post-header__reply'>
- <div className='dropdown'>
- {ownerOptions}
- </div>
- </li>
- </ul>
- <div className='post-body'>
- <p>{message}</p>
- {fileAttachment}
- </div>
- </div>
- <hr />
- </div>
- );
- }
-});
-
-CommentPost = React.createClass({
- retryComment: function(e) {
- e.preventDefault();
-
- var post = this.props.post;
- client.createPost(post, post.channel_id,
- function success(data) {
- AsyncClient.getPosts(true);
-
- var channel = ChannelStore.get(post.channel_id);
- var member = ChannelStore.getMember(post.channel_id);
- member.msg_count = channel.total_msg_count;
- member.last_viewed_at = (new Date()).getTime();
- ChannelStore.setChannelMember(member);
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_POST,
- post: data
- });
- }.bind(this),
- function fail() {
- post.state = Constants.POST_FAILED;
- PostStore.updatePendingPost(post);
- this.forceUpdate();
- }.bind(this)
- );
-
- post.state = Constants.POST_LOADING;
- PostStore.updatePendingPost(post);
- this.forceUpdate();
- },
- render: function() {
- var post = this.props.post;
-
- var currentUserCss = '';
- if (UserStore.getCurrentId() === post.user_id) {
- currentUserCss = 'current--user';
- }
-
- var isOwner = UserStore.getCurrentId() === post.user_id;
-
- var type = 'Post';
- if (post.root_id.length > 0) {
- type = 'Comment';
- }
-
- var message = utils.textToJsx(post.message);
- var timestamp = UserStore.getCurrentUser().update_at;
-
- var loading;
- var postClass = '';
- if (post.state === Constants.POST_FAILED) {
- postClass += ' post-fail';
- loading = <a className='theme post-retry pull-right' href='#' onClick={this.retryComment}>Retry</a>;
- } else if (post.state === Constants.POST_LOADING) {
- postClass += ' post-waiting';
- loading = <img className='post-loading-gif pull-right' src='/static/images/load.gif'/>;
- }
-
- var ownerOptions;
- if (isOwner && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING) {
- ownerOptions = (
- <div className='dropdown' onClick={function(e){$('.post-list-holder-by-time').scrollTop($('.post-list-holder-by-time').scrollTop() + 50);}}>
- <a href='#' className='dropdown-toggle theme' type='button' data-toggle='dropdown' aria-expanded='false' />
- <ul className='dropdown-menu' role='menu'>
- <li role='presentation'><a href='#' role='menuitem' data-toggle='modal' data-target='#edit_post' data-refoucsid='#reply_textbox' data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li>
- <li role='presentation'><a href='#' role='menuitem' data-toggle='modal' data-target='#delete_post' data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={0}>Delete</a></li>
- </ul>
- </div>
- );
- }
-
- var fileAttachment;
- if (post.filenames && post.filenames.length > 0) {
- fileAttachment = (
- <FileAttachmentList
- filenames={post.filenames}
- modalId={'rhs_comment_view_image_modal_' + post.id}
- channelId={post.channel_id}
- userId={post.user_id} />
- );
- }
-
- return (
- <div className={'post ' + currentUserCss}>
- <div className='post-profile-img__container'>
- <img className='post-profile-img' src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp} height='36' width='36' />
- </div>
- <div className='post__content'>
- <ul className='post-header'>
- <li className='post-header-col'><strong><UserProfile userId={post.user_id} /></strong></li>
- <li className='post-header-col'><time className='post-right-comment-time'>{utils.displayCommentDateTime(post.create_at)}</time></li>
- <li className='post-header-col post-header__reply'>
- {ownerOptions}
- </li>
- </ul>
- <div className='post-body'>
- <p className={postClass}>{loading}{message}</p>
- {fileAttachment}
- </div>
- </div>
- </div>
- );
- }
-});
-
-function getStateFromStores() {
- var postList = PostStore.getSelectedPost();
- if (!postList || postList.order.length < 1) {
- return {postList: {}};
- }
-
- var channelId = postList.posts[postList.order[0]].channel_id;
- var pendingPostList = PostStore.getPendingPosts(channelId);
-
- if (pendingPostList) {
- for (var pid in pendingPostList.posts) {
- postList.posts[pid] = pendingPostList.posts[pid];
- }
- }
-
- return {postList: postList};
-}
-
-module.exports = React.createClass({
- componentDidMount: function() {
- PostStore.addSelectedPostChangeListener(this.onChange);
- PostStore.addChangeListener(this.onChangeAll);
- UserStore.addStatusesChangeListener(this.onTimeChange);
- this.resize();
- var self = this;
- $(window).resize(function() {
- self.resize();
- });
- },
- componentDidUpdate: function() {
- $('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight);
- $('.post-right__scroll').perfectScrollbar('update');
- this.resize();
- },
- componentWillUnmount: function() {
- PostStore.removeSelectedPostChangeListener(this.onChange);
- PostStore.removeChangeListener(this.onChangeAll);
- UserStore.removeStatusesChangeListener(this.onTimeChange);
- },
- onChange: function() {
- if (this.isMounted()) {
- var newState = getStateFromStores();
- if (!utils.areStatesEqual(newState, this.state)) {
- this.setState(newState);
- }
- }
- },
- onChangeAll: function() {
- if (this.isMounted()) {
- // if something was changed in the channel like adding a
- // comment or post then lets refresh the sidebar list
- var currentSelected = PostStore.getSelectedPost();
- if (!currentSelected || currentSelected.order.length === 0) {
- return;
- }
-
- var currentPosts = PostStore.getPosts(currentSelected.posts[currentSelected.order[0]].channel_id);
-
- if (!currentPosts || currentPosts.order.length === 0) {
- return;
- }
-
- if (currentPosts.posts[currentPosts.order[0]].channel_id === currentSelected.posts[currentSelected.order[0]].channel_id) {
- currentSelected.posts = {};
- for (var postId in currentPosts.posts) {
- currentSelected.posts[postId] = currentPosts.posts[postId];
- }
-
- PostStore.storeSelectedPost(currentSelected);
- }
-
- this.setState(getStateFromStores());
- }
- },
- onTimeChange: function() {
- for (var id in this.state.postList.posts) {
- if (!this.refs[id]) {
- continue;
- }
- this.refs[id].forceUpdate();
- }
- },
- getInitialState: function() {
- return getStateFromStores();
- },
- resize: function() {
- var height = $(window).height() - $('#error_bar').outerHeight() - 100;
- $('.post-right__scroll').css('height', height + 'px');
- $('.post-right__scroll').scrollTop(100000);
- $('.post-right__scroll').perfectScrollbar();
- $('.post-right__scroll').perfectScrollbar('update');
- },
- render: function() {
- var postList = this.state.postList;
-
- if (postList == null) {
- return (
- <div></div>
- );
- }
-
- var selectedPost = postList.posts[postList.order[0]];
- var rootPost = null;
-
- if (selectedPost.root_id === '') {
- rootPost = selectedPost;
- } else {
- rootPost = postList.posts[selectedPost.root_id];
- }
-
- var postsArray = [];
-
- for (var postId in postList.posts) {
- var cpost = postList.posts[postId];
- if (cpost.root_id === rootPost.id) {
- postsArray.push(cpost);
- }
- }
-
- postsArray.sort(function postSort(a, b) {
- if (a.create_at < b.create_at) {
- return -1;
- }
- if (a.create_at > b.create_at) {
- return 1;
- }
- return 0;
- });
-
- var currentId = UserStore.getCurrentId();
- var searchForm;
- if (currentId != null) {
- searchForm = <SearchBox />;
- }
-
- return (
- <div className='post-right__container'>
- <FileUploadOverlay
- overlayType='right' />
- <div className='search-bar__container sidebar--right__search-header'>{searchForm}</div>
- <div className='sidebar-right__body'>
- <RhsHeaderPost fromSearch={this.props.fromSearch} isMentionSearch={this.props.isMentionSearch} />
- <div className='post-right__scroll'>
- <RootPost post={rootPost} commentCount={postsArray.length}/>
- <div className='post-right-comments-container'>
- {postsArray.map(function mapPosts(comPost) {
- return <CommentPost ref={comPost.id} key={comPost.id} post={comPost} selected={(comPost.id === selectedPost.id)} />;
- })}
- </div>
- <div className='post-create__container'>
- <CreateComment channelId={rootPost.channel_id} rootId={rootPost.id} />
- </div>
- </div>
- </div>
- </div>
- );
- }
-});
diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx
new file mode 100644
index 000000000..7df2fed9e
--- /dev/null
+++ b/web/react/components/rhs_comment.jsx
@@ -0,0 +1,207 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PostStore = require('../stores/post_store.jsx');
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserProfile = require('./user_profile.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var utils = require('../utils/utils.jsx');
+var Constants = require('../utils/constants.jsx');
+var FileAttachmentList = require('./file_attachment_list.jsx');
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+export default class RhsComment extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.retryComment = this.retryComment.bind(this);
+
+ this.state = {};
+ }
+ retryComment(e) {
+ e.preventDefault();
+
+ var post = this.props.post;
+ client.createPost(post, post.channel_id,
+ function success(data) {
+ AsyncClient.getPosts(post.channel_id);
+
+ var channel = ChannelStore.get(post.channel_id);
+ var member = ChannelStore.getMember(post.channel_id);
+ member.msg_count = channel.total_msg_count;
+ member.last_viewed_at = (new Date()).getTime();
+ ChannelStore.setChannelMember(member);
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST,
+ post: data
+ });
+ },
+ function fail() {
+ post.state = Constants.POST_FAILED;
+ PostStore.updatePendingPost(post);
+ this.forceUpdate();
+ }.bind(this)
+ );
+
+ post.state = Constants.POST_LOADING;
+ PostStore.updatePendingPost(post);
+ this.forceUpdate();
+ }
+ shouldComponentUpdate(nextProps) {
+ if (!utils.areStatesEqual(nextProps.post, this.props.post)) {
+ return true;
+ }
+
+ return false;
+ }
+ render() {
+ var post = this.props.post;
+
+ var currentUserCss = '';
+ if (UserStore.getCurrentId() === post.user_id) {
+ currentUserCss = 'current--user';
+ }
+
+ var isOwner = UserStore.getCurrentId() === post.user_id;
+
+ var type = 'Post';
+ if (post.root_id.length > 0) {
+ type = 'Comment';
+ }
+
+ var message = utils.textToJsx(post.message);
+ var timestamp = UserStore.getCurrentUser().update_at;
+
+ var loading;
+ var postClass = '';
+ if (post.state === Constants.POST_FAILED) {
+ postClass += ' post-fail';
+ loading = (
+ <a
+ className='theme post-retry pull-right'
+ href='#'
+ onClick={this.retryComment}
+ >
+ Retry
+ </a>
+ );
+ } else if (post.state === Constants.POST_LOADING) {
+ postClass += ' post-waiting';
+ loading = (
+ <img
+ className='post-loading-gif pull-right'
+ src='/static/images/load.gif'
+ />
+ );
+ }
+
+ var ownerOptions;
+ if (isOwner && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING) {
+ ownerOptions = (
+ <div
+ className='dropdown'
+ onClick={
+ function scroll() {
+ $('.post-list-holder-by-time').scrollTop($('.post-list-holder-by-time').scrollTop() + 50);
+ }
+ }
+ >
+ <a
+ href='#'
+ className='dropdown-toggle theme'
+ type='button'
+ data-toggle='dropdown'
+ aria-expanded='false'
+ />
+ <ul
+ className='dropdown-menu'
+ role='menu'
+ >
+ <li role='presentation'>
+ <a
+ href='#'
+ role='menuitem'
+ data-toggle='modal'
+ data-target='#edit_post'
+ data-title={type}
+ data-message={post.message}
+ data-postid={post.id}
+ data-channelid={post.channel_id}
+ >
+ Edit
+ </a>
+ </li>
+ <li role='presentation'>
+ <a
+ href='#'
+ role='menuitem'
+ data-toggle='modal'
+ data-target='#delete_post'
+ data-title={type}
+ data-postid={post.id}
+ data-channelid={post.channel_id}
+ data-comments={0}
+ >
+ Delete
+ </a>
+ </li>
+ </ul>
+ </div>
+ );
+ }
+
+ var fileAttachment;
+ if (post.filenames && post.filenames.length > 0) {
+ fileAttachment = (
+ <FileAttachmentList
+ filenames={post.filenames}
+ modalId={'rhs_comment_view_image_modal_' + post.id}
+ channelId={post.channel_id}
+ userId={post.user_id} />
+ );
+ }
+
+ return (
+ <div className={'post ' + currentUserCss}>
+ <div className='post-profile-img__container'>
+ <img
+ className='post-profile-img'
+ src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp}
+ height='36'
+ width='36'
+ />
+ </div>
+ <div className='post__content'>
+ <ul className='post-header'>
+ <li className='post-header-col'>
+ <strong><UserProfile userId={post.user_id} /></strong>
+ </li>
+ <li className='post-header-col'>
+ <time className='post-right-comment-time'>
+ {utils.displayCommentDateTime(post.create_at)}
+ </time>
+ </li>
+ <li className='post-header-col post-header__reply'>
+ {ownerOptions}
+ </li>
+ </ul>
+ <div className='post-body'>
+ <p className={postClass}>{loading}{message}</p>
+ {fileAttachment}
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+RhsComment.defaultProps = {
+ post: null
+};
+RhsComment.propTypes = {
+ post: React.PropTypes.object
+};
diff --git a/web/react/components/rhs_header_post.jsx b/web/react/components/rhs_header_post.jsx
new file mode 100644
index 000000000..4cf4231e9
--- /dev/null
+++ b/web/react/components/rhs_header_post.jsx
@@ -0,0 +1,81 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+export default class RhsHeaderPost extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleClose = this.handleClose.bind(this);
+ this.handleBack = this.handleBack.bind(this);
+
+ this.state = {};
+ }
+ handleClose(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH,
+ results: null
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ }
+ handleBack(e) {
+ e.preventDefault();
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_SEARCH_TERM,
+ term: this.props.fromSearch,
+ do_search: true,
+ is_mention_search: this.props.isMentionSearch
+ });
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST_SELECTED,
+ results: null
+ });
+ }
+ render() {
+ var back;
+ if (this.props.fromSearch) {
+ back = (
+ <a
+ href='#'
+ onClick={this.handleBack}
+ className='sidebar--right__back'
+ >
+ <i className='fa fa-chevron-left'></i>
+ </a>
+ );
+ }
+
+ return (
+ <div className='sidebar--right__header'>
+ <span className='sidebar--right__title'>{back}Message Details</span>
+ <button
+ type='button'
+ className='sidebar--right__close'
+ aria-label='Close'
+ onClick={this.handleClose}
+ >
+ </button>
+ </div>
+ );
+ }
+}
+
+RhsHeaderPost.defaultProps = {
+ isMentionSearch: false,
+ fromSearch: ''
+};
+RhsHeaderPost.propTypes = {
+ isMentionSearch: React.PropTypes.bool,
+ fromSearch: React.PropTypes.string
+};
diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx
new file mode 100644
index 000000000..a407e6470
--- /dev/null
+++ b/web/react/components/rhs_root_post.jsx
@@ -0,0 +1,145 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var ChannelStore = require('../stores/channel_store.jsx');
+var UserProfile = require('./user_profile.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var utils = require('../utils/utils.jsx');
+var FileAttachmentList = require('./file_attachment_list.jsx');
+
+export default class RhsRootPost extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {};
+ }
+ shouldComponentUpdate(nextProps) {
+ if (!utils.areStatesEqual(nextProps.post, this.props.post)) {
+ return true;
+ }
+
+ return false;
+ }
+ render() {
+ var post = this.props.post;
+ var message = utils.textToJsx(post.message);
+ var isOwner = UserStore.getCurrentId() === post.user_id;
+ var timestamp = UserStore.getProfile(post.user_id).update_at;
+ var channel = ChannelStore.get(post.channel_id);
+
+ var type = 'Post';
+ if (post.root_id.length > 0) {
+ type = 'Comment';
+ }
+
+ var currentUserCss = '';
+ if (UserStore.getCurrentId() === post.user_id) {
+ currentUserCss = 'current--user';
+ }
+
+ var channelName;
+ if (channel) {
+ if (channel.type === 'D') {
+ channelName = 'Private Message';
+ } else {
+ channelName = channel.display_name;
+ }
+ }
+
+ var ownerOptions;
+ if (isOwner) {
+ ownerOptions = (
+ <div>
+ <a href='#'
+ className='dropdown-toggle theme'
+ type='button'
+ data-toggle='dropdown'
+ aria-expanded='false'
+ />
+ <ul
+ className='dropdown-menu'
+ role='menu'
+ >
+ <li role='presentation'>
+ <a
+ href='#'
+ role='menuitem'
+ data-toggle='modal'
+ data-target='#edit_post'
+ data-title={type}
+ data-message={post.message}
+ data-postid={post.id}
+ data-channelid={post.channel_id}
+ >
+ Edit
+ </a>
+ </li>
+ <li role='presentation'>
+ <a
+ href='#'
+ role='menuitem'
+ data-toggle='modal'
+ data-target='#delete_post'
+ data-title={type}
+ data-postid={post.id}
+ data-channelid={post.channel_id}
+ data-comments={this.props.commentCount}
+ >
+ Delete
+ </a>
+ </li>
+ </ul>
+ </div>
+ );
+ }
+
+ var fileAttachment;
+ if (post.filenames && post.filenames.length > 0) {
+ fileAttachment = (
+ <FileAttachmentList
+ filenames={post.filenames}
+ modalId={'rhs_view_image_modal_' + post.id}
+ channelId={post.channel_id}
+ userId={post.user_id} />
+ );
+ }
+
+ return (
+ <div className={'post post--root ' + currentUserCss}>
+ <div className='post-right-channel__name'>{channelName}</div>
+ <div className='post-profile-img__container'>
+ <img
+ className='post-profile-img'
+ src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp}
+ height='36'
+ width='36'
+ />
+ </div>
+ <div className='post__content'>
+ <ul className='post-header'>
+ <li className='post-header-col'><strong><UserProfile userId={post.user_id} /></strong></li>
+ <li className='post-header-col'><time className='post-right-root-time'>{utils.displayCommentDateTime(post.create_at)}</time></li>
+ <li className='post-header-col post-header__reply'>
+ <div className='dropdown'>
+ {ownerOptions}
+ </div>
+ </li>
+ </ul>
+ <div className='post-body'>
+ <p>{message}</p>
+ {fileAttachment}
+ </div>
+ </div>
+ <hr />
+ </div>
+ );
+ }
+}
+
+RhsRootPost.defaultProps = {
+ post: null,
+ commentCount: 0
+};
+RhsRootPost.propTypes = {
+ post: React.PropTypes.object,
+ commentCount: React.PropTypes.number
+};
diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx
new file mode 100644
index 000000000..adddeccf0
--- /dev/null
+++ b/web/react/components/rhs_thread.jsx
@@ -0,0 +1,215 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var PostStore = require('../stores/post_store.jsx');
+var UserStore = require('../stores/user_store.jsx');
+var utils = require('../utils/utils.jsx');
+var SearchBox = require('./search_bar.jsx');
+var CreateComment = require('./create_comment.jsx');
+var RhsHeaderPost = require('./rhs_header_post.jsx');
+var RootPost = require('./rhs_root_post.jsx');
+var Comment = require('./rhs_comment.jsx');
+var Constants = require('../utils/constants.jsx');
+var FileUploadOverlay = require('./file_upload_overlay.jsx');
+
+export default class RhsThread extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.onChange = this.onChange.bind(this);
+ this.onChangeAll = this.onChangeAll.bind(this);
+ this.onTimeChange = this.onTimeChange.bind(this);
+
+ this.state = this.getStateFromStores();
+ }
+ getStateFromStores() {
+ var postList = PostStore.getSelectedPost();
+ if (!postList || postList.order.length < 1) {
+ return {postList: {}};
+ }
+
+ var channelId = postList.posts[postList.order[0]].channel_id;
+ var pendingPostList = PostStore.getPendingPosts(channelId);
+
+ if (pendingPostList) {
+ for (var pid in pendingPostList.posts) {
+ postList.posts[pid] = pendingPostList.posts[pid];
+ }
+ }
+
+ return {postList: postList};
+ }
+ componentDidMount() {
+ PostStore.addSelectedPostChangeListener(this.onChange);
+ PostStore.addChangeListener(this.onChangeAll);
+ UserStore.addStatusesChangeListener(this.onTimeChange);
+ this.resize();
+ $(window).resize(function resize() {
+ this.resize();
+ }.bind(this));
+ }
+ componentDidUpdate() {
+ $('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight);
+ $('.post-right__scroll').perfectScrollbar('update');
+ this.resize();
+ }
+ componentWillUnmount() {
+ PostStore.removeSelectedPostChangeListener(this.onChange);
+ PostStore.removeChangeListener(this.onChangeAll);
+ UserStore.removeStatusesChangeListener(this.onTimeChange);
+ }
+ onChange() {
+ var newState = this.getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ onChangeAll() {
+ // if something was changed in the channel like adding a
+ // comment or post then lets refresh the sidebar list
+ var currentSelected = PostStore.getSelectedPost();
+ if (!currentSelected || currentSelected.order.length === 0) {
+ return;
+ }
+
+ var currentPosts = PostStore.getPosts(currentSelected.posts[currentSelected.order[0]].channel_id);
+
+ if (!currentPosts || currentPosts.order.length === 0) {
+ return;
+ }
+
+ if (currentPosts.posts[currentPosts.order[0]].channel_id === currentSelected.posts[currentSelected.order[0]].channel_id) {
+ currentSelected.posts = {};
+ for (var postId in currentPosts.posts) {
+ currentSelected.posts[postId] = currentPosts.posts[postId];
+ }
+
+ PostStore.storeSelectedPost(currentSelected);
+ }
+
+ var newState = this.getStateFromStores();
+ if (!utils.areStatesEqual(newState, this.state)) {
+ this.setState(newState);
+ }
+ }
+ onTimeChange() {
+ for (var id in this.state.postList.posts) {
+ if (!this.refs[id]) {
+ continue;
+ }
+ this.refs[id].forceUpdate();
+ }
+ }
+ resize() {
+ var height = $(window).height() - $('#error_bar').outerHeight() - 100;
+ $('.post-right__scroll').css('height', height + 'px');
+ $('.post-right__scroll').scrollTop(100000);
+ $('.post-right__scroll').perfectScrollbar();
+ $('.post-right__scroll').perfectScrollbar('update');
+ }
+ render() {
+ var postList = this.state.postList;
+
+ if (postList == null) {
+ return (
+ <div></div>
+ );
+ }
+
+ var selectedPost = postList.posts[postList.order[0]];
+ var rootPost = null;
+
+ if (selectedPost.root_id === '') {
+ rootPost = selectedPost;
+ } else {
+ rootPost = postList.posts[selectedPost.root_id];
+ }
+
+ var postsArray = [];
+
+ for (var postId in postList.posts) {
+ var cpost = postList.posts[postId];
+ if (cpost.root_id === rootPost.id) {
+ postsArray.push(cpost);
+ }
+ }
+
+ // sort failed posts to bottom, followed by pending, and then regular posts
+ postsArray.sort(function postSort(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;
+ }
+ if ((a.state !== Constants.POST_LOADING && a.state !== Constants.POST_FAILED) && (b.state === Constants.POST_LOADING || b.state === Constants.POST_FAILED)) {
+ return -1;
+ }
+
+ if (a.state === Constants.POST_LOADING && b.state === Constants.POST_FAILED) {
+ return -1;
+ }
+ if (a.state === Constants.POST_FAILED && b.state === Constants.POST_LOADING) {
+ return 1;
+ }
+
+ if (a.create_at < b.create_at) {
+ return -1;
+ }
+ if (a.create_at > b.create_at) {
+ return 1;
+ }
+ return 0;
+ });
+
+ var currentId = UserStore.getCurrentId();
+ var searchForm;
+ if (currentId != null) {
+ searchForm = <SearchBox />;
+ }
+
+ return (
+ <div className='post-right__container'>
+ <FileUploadOverlay
+ overlayType='right' />
+ <div className='search-bar__container sidebar--right__search-header'>{searchForm}</div>
+ <div className='sidebar-right__body'>
+ <RhsHeaderPost
+ fromSearch={this.props.fromSearch}
+ isMentionSearch={this.props.isMentionSearch}
+ />
+ <div className='post-right__scroll'>
+ <RootPost
+ post={rootPost}
+ commentCount={postsArray.length}
+ />
+ <div className='post-right-comments-container'>
+ {postsArray.map(function mapPosts(comPost) {
+ return (
+ <Comment
+ ref={comPost.id}
+ key={comPost.id}
+ post={comPost}
+ selected={(comPost.id === selectedPost.id)}
+ />
+ );
+ })}
+ </div>
+ <div className='post-create__container'>
+ <CreateComment
+ channelId={rootPost.channel_id}
+ rootId={rootPost.id}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+RhsThread.defaultProps = {
+ fromSearch: '',
+ isMentionSearch: false
+};
+RhsThread.propTypes = {
+ fromSearch: React.PropTypes.string,
+ isMentionSearch: React.PropTypes.bool
+};
diff --git a/web/react/components/setting_upload.jsx b/web/react/components/setting_upload.jsx
index 02789f5dd..596324308 100644
--- a/web/react/components/setting_upload.jsx
+++ b/web/react/components/setting_upload.jsx
@@ -8,7 +8,8 @@ module.exports = React.createClass({
submit: React.PropTypes.func.isRequired,
fileTypesAccepted: React.PropTypes.string.isRequired,
clientError: React.PropTypes.string,
- serverError: React.PropTypes.string
+ serverError: React.PropTypes.string,
+ helpText: React.PropTypes.string
},
getInitialState: function() {
return {
@@ -38,14 +39,6 @@ module.exports = React.createClass({
this.setState({clientError: 'No file selected.'});
}
},
- doCancel: function(e) {
- e.preventDefault();
- this.refs.uploadinput.getDOMNode().value = '';
- this.setState({
- clientError: '',
- serverError: ''
- });
- },
onFileSelect: function(e) {
var filename = $(e.target).val();
if (filename.substring(3, 11) === 'fakepath') {
@@ -70,6 +63,7 @@ module.exports = React.createClass({
return (
<ul className='section-max'>
<li className='col-xs-12 section-title'>{this.props.title}</li>
+ <li className='col-xs-offset-3'>{this.props.helpText}</li>
<li className='col-xs-offset-3 col-xs-8'>
<ul className='setting-list'>
<li className='setting-list-item'>
@@ -79,12 +73,6 @@ module.exports = React.createClass({
onClick={this.doSubmit}>
Import
</a>
- <a
- className='btn btn-sm btn-link theme'
- href='#'
- onClick={this.doCancel}>
- Cancel
- </a>
<div className='file-status file-name hide'></div>
{serverError}
{clientError}
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index d79505e9e..8dd192893 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -64,7 +64,8 @@ function getStateFromStores() {
var tempChannel = {};
tempChannel.fake = true;
tempChannel.name = channelName;
- tempChannel.display_name = utils.getDisplayName(teammate);
+ tempChannel.display_name = teammate.username;
+ tempChannel.teammate_username = teammate.username;
tempChannel.status = UserStore.getStatus(teammate.id);
tempChannel.last_post_at = 0;
tempChannel.total_msg_count = 0;
diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx
index 8334b345b..df75e3adf 100644
--- a/web/react/components/sidebar_right.jsx
+++ b/web/react/components/sidebar_right.jsx
@@ -1,11 +1,9 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-
-var SearchResults =require('./search_results.jsx');
-var PostRight =require('./post_right.jsx');
+var SearchResults = require('./search_results.jsx');
+var RhsThread = require('./rhs_thread.jsx');
var PostStore = require('../stores/post_store.jsx');
-var Constants = require('../utils/constants.jsx');
var utils = require('../utils/utils.jsx');
function getStateFromStores(from_search) {
@@ -39,8 +37,8 @@ module.exports = React.createClass({
}
},
resize: function() {
- $(".post-list-holder-by-time").scrollTop(100000);
- $(".post-list-holder-by-time").perfectScrollbar('update');
+ var postHolder = $('.post-list-holder-by-time');
+ postHolder[0].scrollTop = postHolder[0].scrollHeight - 224;
},
getInitialState: function() {
return getStateFromStores();
@@ -72,7 +70,7 @@ module.exports = React.createClass({
content = <SearchResults isMentionSearch={this.state.is_mention_search} />;
}
else if (this.state.post_right_visible) {
- content = <PostRight fromSearch={this.state.from_search} isMentionSearch={this.state.is_mention_search} />;
+ content = <RhsThread fromSearch={this.state.from_search} isMentionSearch={this.state.is_mention_search} />;
}
return (
diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx
index d221ca840..615bc4ef2 100644
--- a/web/react/components/sidebar_right_menu.jsx
+++ b/web/react/components/sidebar_right_menu.jsx
@@ -15,7 +15,6 @@ module.exports = React.createClass({
var inviteLink = '';
var teamSettingsLink = '';
var manageLink = '';
- var renameLink = '';
var currentUser = UserStore.getCurrentUser();
var isAdmin = false;
@@ -48,11 +47,6 @@ module.exports = React.createClass({
<a href='#' data-toggle='modal' data-target='#team_members'><i className='glyphicon glyphicon-wrench'></i>Manage Team</a>
</li>
);
- renameLink = (
- <li>
- <a href='#' data-toggle='modal' data-target='#rename_team_link'><i className='glyphicon glyphicon-pencil'></i>Rename</a>
- </li>
- );
}
var siteName = '';
@@ -77,7 +71,6 @@ module.exports = React.createClass({
{inviteLink}
{teamLink}
{manageLink}
- {renameLink}
<li><a href='#' onClick={this.handleLogoutClick}><i className='glyphicon glyphicon-log-out'></i>Logout</a></li>
<li className='divider'></li>
<li><a target='_blank' href='/static/help/configure_links.html'><i className='glyphicon glyphicon-question-sign'></i>Help</a></li>
diff --git a/web/react/components/team_import_tab.jsx b/web/react/components/team_import_tab.jsx
index c21701c0e..e3415d7f4 100644
--- a/web/react/components/team_import_tab.jsx
+++ b/web/react/components/team_import_tab.jsx
@@ -20,10 +20,20 @@ module.exports = React.createClass({
utils.importSlack(file, this.onImportSuccess, this.onImportFailure);
},
render: function() {
+ var uploadHelpText = (
+ <div>
+ <br/>
+ Slack does not allow you to export files, images, private groups or direct messages stored in Slack. Therefore, Slack import to Mattermost only supports importing of text messages in your Slack team's public channels.
+ <br/><br/>
+ The Slack import to Mattermost is in "Preview". Slack bot posts and channels with underscores do not yet import.
+ <br/><br/>
+ </div>
+ );
var uploadSection = (
<SettingUpload
title='Import from Slack'
submit={this.doImportSlack}
+ helpText={uploadHelpText}
fileTypesAccepted='.zip'/>
);
@@ -39,12 +49,12 @@ module.exports = React.createClass({
break;
case 'done':
messageSection = (
- <p className="confirm-import alert alert-success"><i className="fa fa-check"></i> Import sucessfull: <a href={this.state.link} download='MattermostImportSummery.txt'>View Summery</a></p>
+ <p className="confirm-import alert alert-success"><i className="fa fa-check"></i> Import successful: <a href={this.state.link} download='MattermostImportSummary.txt'>View Summary</a></p>
);
break;
case 'fail':
messageSection = (
- <p className="confirm-import alert alert-warning"><i className="fa fa-warning"></i> Import failure: <a href={this.state.link} download='MattermostImportSummery.txt'>View Summery</a></p>
+ <p className="confirm-import alert alert-warning"><i className="fa fa-warning"></i> Import failure: <a href={this.state.link} download='MattermostImportSummary.txt'>View Summary</a></p>
);
break;
}
diff --git a/web/react/components/team_signup_password_page.jsx b/web/react/components/team_signup_password_page.jsx
index e4f35f100..bbe82a5c2 100644
--- a/web/react/components/team_signup_password_page.jsx
+++ b/web/react/components/team_signup_password_page.jsx
@@ -31,31 +31,35 @@ module.exports = React.createClass({
teamSignup.user.allow_marketing = true;
delete teamSignup.wizard;
- // var ctl = this;
-
client.createTeamFromSignup(teamSignup,
function success() {
client.track('signup', 'signup_team_08_complete');
var props = this.props;
- $('#sign-up-button').button('reset');
- props.state.wizard = 'finished';
- props.updateParent(props.state, true);
+
+ client.loginByEmail(teamSignup.team.name, teamSignup.team.email, teamSignup.user.password,
+ function(data) {
+ UserStore.setLastEmail(teamSignup.team.email);
+ UserStore.setCurrentUser(teamSignup.user);
+ if (this.props.hash > 0) {
+ BrowserStore.setGlobalItem(this.props.hash, JSON.stringify({wizard: 'finished'}));
+ }
- window.location.href = utils.getWindowLocationOrigin() + '/' + props.state.team.name + '/login?email=' + encodeURIComponent(teamSignup.team.email);
+ $('#sign-up-button').button('reset');
+ props.state.wizard = 'finished';
+ props.updateParent(props.state, true);
- // client.loginByEmail(teamSignup.team.domain, teamSignup.team.email, teamSignup.user.password,
- // function(data) {
- // TeamStore.setLastName(teamSignup.team.domain);
- // UserStore.setLastEmail(teamSignup.team.email);
- // UserStore.setCurrentUser(data);
- // window.location.href = '/channels/town-square';
- // }.bind(ctl),
- // function(err) {
- // this.setState({nameError: err.message});
- // }.bind(ctl)
- // );
+ window.location.href = '/';
+ }.bind(this),
+ function(err) {
+ if (err.message === 'Login failed because email address has not been verified') {
+ window.location.href = '/verify_email?email=' + encodeURIComponent(teamSignup.team.email) + '&teamname=' + encodeURIComponent(teamSignup.team.name);
+ } else {
+ this.setState({serverError: err.message});
+ }
+ }.bind(this)
+ );
}.bind(this),
function error(err) {
this.setState({serverError: err.message});
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index b5c5cc564..efd2dd810 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -257,7 +257,7 @@ module.exports = React.createClass({
return (
<div ref='wrapper' className='textarea-wrapper'>
<CommandList ref='commands' addCommand={this.addCommand} channelId={this.props.channelId} />
- <textarea id={this.props.id} ref='message' className={'form-control custom-textarea ' + this.state.connection} spellCheck='true' autoComplete='off' autoCorrect='off' rows='1' placeholder={this.props.createMessage} value={this.props.messageText} onInput={this.handleChange} onChange={this.handleChange} onKeyPress={this.handleKeyPress} onKeyDown={this.handleKeyDown} onFocus={this.handleFocus} onBlur={this.handleBlur} onPaste={this.handlePaste} />
+ <textarea id={this.props.id} ref='message' className={'form-control custom-textarea ' + this.state.connection} spellCheck='true' autoComplete='off' autoCorrect='off' rows='1' maxLength={Constants.MAX_POST_LEN} placeholder={this.props.createMessage} value={this.props.messageText} onInput={this.handleChange} onChange={this.handleChange} onKeyPress={this.handleKeyPress} onKeyDown={this.handleKeyDown} onFocus={this.handleFocus} onBlur={this.handleBlur} onPaste={this.handlePaste} />
</div>
);
}
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
index 2fffb17d0..4038814d2 100644
--- a/web/react/stores/post_store.jsx
+++ b/web/react/stores/post_store.jsx
@@ -99,8 +99,52 @@ var PostStore = assign({}, EventEmitter.prototype, {
}
return null;
},
- storePosts: function storePosts(channelId, posts) {
- this.pStorePosts(channelId, posts);
+ storePosts: function storePosts(channelId, newPostList) {
+ if (isPostListNull(newPostList)) {
+ return;
+ }
+
+ var postList = makePostListNonNull(PostStore.getPosts(channelId));
+
+ for (var pid in newPostList.posts) {
+ var np = newPostList.posts[pid];
+ if (np.delete_at === 0) {
+ postList.posts[pid] = np;
+ if (postList.order.indexOf(pid) === -1) {
+ postList.order.push(pid);
+ }
+ } else {
+ if (pid in postList.posts) {
+ delete postList.posts[pid];
+ }
+
+ var index = postList.order.indexOf(pid);
+ if (index !== -1) {
+ postList.order.splice(index, 1);
+ }
+ }
+ }
+
+ postList.order.sort(function postSort(a, b) {
+ if (postList.posts[a].create_at > postList.posts[b].create_at) {
+ return -1;
+ }
+ if (postList.posts[a].create_at < postList.posts[b].create_at) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ var latestUpdate = 0;
+ for (var pid in postList.posts) {
+ if (postList.posts[pid].update_at > latestUpdate) {
+ latestUpdate = postList.posts[pid].update_at;
+ }
+ }
+
+ this.storeLatestUpdate(channelId, latestUpdate);
+ this.pStorePosts(channelId, postList);
this.emitChange();
},
pStorePosts: function pStorePosts(channelId, posts) {
@@ -115,9 +159,7 @@ var PostStore = assign({}, EventEmitter.prototype, {
},
pStorePost: function(post) {
var postList = PostStore.getPosts(post.channel_id);
- if (!postList) {
- return;
- }
+ postList = makePostListNonNull(postList);
if (post.pending_post_id !== '') {
this.removePendingPost(post.channel_id, post.pending_post_id);
@@ -132,13 +174,28 @@ var PostStore = assign({}, EventEmitter.prototype, {
this.pStorePosts(post.channel_id, postList);
},
+ removePost: function(postId, channelId) {
+ var postList = PostStore.getPosts(channelId);
+ if (isPostListNull(postList)) {
+ return;
+ }
+
+ if (postId in postList.posts) {
+ delete postList.posts[postId];
+ }
+
+ var index = postList.order.indexOf(postId);
+ if (index !== -1) {
+ postList.order.splice(index, 1);
+ }
+
+ this.pStorePosts(channelId, postList);
+ },
storePendingPost: function(post) {
post.state = Constants.POST_LOADING;
var postList = this.getPendingPosts(post.channel_id);
- if (!postList) {
- postList = {posts: {}, order: []};
- }
+ postList = makePostListNonNull(postList);
postList.posts[post.pending_post_id] = post;
postList.order.unshift(post.pending_post_id);
@@ -200,15 +257,13 @@ var PostStore = assign({}, EventEmitter.prototype, {
},
_removePendingPost: function(channelId, pendingPostId) {
var postList = this.getPendingPosts(channelId);
- if (!postList) {
- return;
- }
+ postList = makePostListNonNull(postList);
if (pendingPostId in postList.posts) {
delete postList.posts[pendingPostId];
}
var index = postList.order.indexOf(pendingPostId);
- if (index >= 0) {
+ if (index !== -1) {
postList.order.splice(index, 1);
}
@@ -221,9 +276,7 @@ var PostStore = assign({}, EventEmitter.prototype, {
},
updatePendingPost: function(post) {
var postList = this.getPendingPosts(post.channel_id);
- if (!postList) {
- postList = {posts: {}, order: []};
- }
+ postList = makePostListNonNull(postList);
if (postList.order.indexOf(post.pending_post_id) === -1) {
return;
@@ -293,6 +346,12 @@ var PostStore = assign({}, EventEmitter.prototype, {
BrowserStore.setItem(key, value);
}
});
+ },
+ storeLatestUpdate: function(channelId, time) {
+ BrowserStore.setItem('latest_post_' + channelId, time);
+ },
+ getLatestUpdate: function(channelId) {
+ return BrowserStore.getItem('latest_post_' + channelId, 0);
}
});
@@ -301,8 +360,7 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) {
switch (action.type) {
case ActionTypes.RECIEVED_POSTS:
- PostStore.pStorePosts(action.id, action.post_list);
- PostStore.emitChange();
+ PostStore.storePosts(action.id, makePostListNonNull(action.post_list));
break;
case ActionTypes.RECIEVED_POST:
PostStore.pStorePost(action.post);
@@ -331,3 +389,36 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) {
});
module.exports = PostStore;
+
+function makePostListNonNull(pl) {
+ var postList = pl;
+ if (postList == null) {
+ postList = {order: [], posts: {}};
+ }
+
+ if (postList.order == null) {
+ postList.order = [];
+ }
+
+ if (postList.posts == null) {
+ postList.posts = {};
+ }
+
+ return postList;
+}
+
+function isPostListNull(pl) {
+ if (pl == null) {
+ return true;
+ }
+
+ if (pl.posts == null) {
+ return true;
+ }
+
+ if (pl.order == null) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index 349fe9021..c03a0230b 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -344,14 +344,14 @@ module.exports.search = function(terms) {
);
}
-module.exports.getPosts = function(force, id, maxPosts) {
+module.exports.getPostsPage = function(force, id, maxPosts) {
if (PostStore.getCurrentPosts() == null || force) {
var channelId = id;
if (channelId == null) {
channelId = ChannelStore.getCurrentId();
}
- if (isCallInProgress('getPosts_' + channelId)) {
+ if (isCallInProgress('getPostsPage_' + channelId)) {
return;
}
@@ -371,9 +371,9 @@ module.exports.getPosts = function(force, id, maxPosts) {
}
if (channelId != null) {
- callTracker['getPosts_' + channelId] = utils.getTimestamp();
+ callTracker['getPostsPage_' + channelId] = utils.getTimestamp();
- client.getPosts(
+ client.getPostsPage(
channelId,
0,
numPosts,
@@ -389,15 +389,63 @@ module.exports.getPosts = function(force, id, maxPosts) {
module.exports.getProfiles();
},
function(err) {
- dispatchError(err, 'getPosts');
+ dispatchError(err, 'getPostsPage');
},
function() {
- callTracker['getPosts_' + channelId] = 0;
+ callTracker['getPostsPage_' + channelId] = 0;
}
);
}
}
+};
+
+function getPosts(id) {
+ var channelId = id;
+ if (channelId == null) {
+ if (ChannelStore.getCurrentId() == null) {
+ return;
+ }
+ channelId = ChannelStore.getCurrentId();
+ }
+
+ if (isCallInProgress('getPosts_' + channelId)) {
+ return;
+ }
+
+ if (PostStore.getCurrentPosts() == null) {
+ module.exports.getPostsPage(true, id, Constants.POST_CHUNK_SIZE);
+ return;
+ }
+
+ var latestUpdate = PostStore.getLatestUpdate(channelId);
+
+ callTracker['getPosts_' + channelId] = utils.getTimestamp();
+
+ client.getPosts(
+ channelId,
+ latestUpdate,
+ function success(data, textStatus, xhr) {
+ if (xhr.status === 304 || !data) {
+ return;
+ }
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POSTS,
+ id: channelId,
+ post_list: data
+ });
+
+ module.exports.getProfiles();
+ },
+ function fail(err) {
+ dispatchError(err, 'getPosts');
+ },
+ function complete() {
+ callTracker['getPosts_' + channelId] = 0;
+ }
+ );
}
+module.exports.getPosts = getPosts;
function getMe() {
if (isCallInProgress('getMe')) {
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 13d6c3f54..70220c71e 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -653,7 +653,7 @@ module.exports.executeCommand = function(channelId, command, suggest, success, e
});
};
-module.exports.getPosts = function(channelId, offset, limit, success, error, complete) {
+module.exports.getPostsPage = function(channelId, offset, limit, success, error, complete) {
$.ajax({
cache: false,
url: '/api/v1/channels/' + channelId + '/posts/' + offset + '/' + limit,
@@ -669,6 +669,21 @@ module.exports.getPosts = function(channelId, offset, limit, success, error, com
});
};
+module.exports.getPosts = function(channelId, since, success, error, complete) {
+ $.ajax({
+ url: '/api/v1/channels/' + channelId + '/posts/' + since,
+ dataType: 'json',
+ type: 'GET',
+ ifModified: true,
+ success: success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('getPosts', xhr, status, err);
+ error(e);
+ },
+ complete: complete
+ });
+};
+
module.exports.getPost = function(channelId, postId, success, error) {
$.ajax({
cache: false,
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 8239a4a69..82fc3da22 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -92,6 +92,7 @@ module.exports = {
],
MONTHS: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
MAX_DMS: 20,
+ MAX_POST_LEN: 4000,
ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>",
OFFLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path fill='#cccccc' d='M6.002,7.143C5.645,7.363,5.167,7.52,4.502,7.52c-2.493,0-2.5-2.02-2.5-2.02S1.029,5.607,0.775,6.004C0.41,6.577,0.15,7.716,0.049,8.545c-0.025,0.145-0.057,0.537-0.05,0.598c0.162,1.295,2.237,2.321,4.375,2.357c0.043,0.001,0.085,0.001,0.127,0.001c0.043,0,0.084,0,0.127-0.001c1.879-0.023,3.793-0.879,4.263-2h-2.89L6.002,7.143L6.002,7.143z M4.501,5.488c1.372,0,2.483-1.117,2.483-2.494c0-1.378-1.111-2.495-2.483-2.495c-1.371,0-2.481,1.117-2.481,2.495C2.02,4.371,3.13,5.488,4.501,5.488z M7.002,6.5v2h5v-2H7.002z'/></g></g></svg>",
MENU_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='4px' height='16px' viewBox='0 0 8 32' enable-background='new 0 0 8 32' xml:space='preserve'> <g> <circle cx='4' cy='4.062' r='4'/> <circle cx='4' cy='16' r='4'/> <circle cx='4' cy='28' r='4'/> </g> </svg>",
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index f19dd2b47..34a0d55da 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -289,7 +289,6 @@ function getYoutubeEmbed(link) {
$('.video-uploader.' + youtubeId).html(metadata.channelTitle);
$('.video-title.' + youtubeId).find('a').html(metadata.title);
$('.post-list-holder-by-time').scrollTop($('.post-list-holder-by-time')[0].scrollHeight);
- $('.post-list-holder-by-time').perfectScrollbar('update');
}
if (config.GoogleDeveloperKey) {
@@ -765,7 +764,7 @@ function switchChannel(channel, teammateName) {
AsyncClient.getChannels(true, true, true);
AsyncClient.getChannelExtraInfo(true);
- AsyncClient.getPosts(true, channel.id, Constants.POST_CHUNK_SIZE);
+ AsyncClient.getPosts(channel.id);
$('.inner__wrap').removeClass('move--right');
$('.sidebar--left').removeClass('move--right');
@@ -987,6 +986,58 @@ module.exports.isBrowserFirefox = function() {
return navigator && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
};
+// Checks if browser is IE10 or IE11
+module.exports.isBrowserIE = function() {
+ if (window.navigator && window.navigator.userAgent) {
+ var ua = window.navigator.userAgent;
+
+ return ua.indexOf('Trident/7.0') > 0 || ua.indexOf('Trident/6.0') > 0;
+ }
+
+ return false;
+};
+
+module.exports.isBrowserEdge = function() {
+ return window.naviagtor && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1;
+};
+
+// Gets text length consistent with maxlength property of textarea html tag
+module.exports.getLengthOfTextInTextarea = function(messageText) {
+ // Need to get length with carriage returns counting as two characters to match textbox maxlength behavior
+ // unless ie10/ie11/edge which already do
+
+ var len = messageText.length;
+ if (!module.exports.isBrowserIE() && !module.exports.isBrowserEdge()) {
+ len = messageText.replace(/\r(?!\n)|\n(?!\r)/g, '--').length;
+ }
+
+ return len;
+};
+
+module.exports.checkMessageLengthError = function(message, currentError, newError) {
+ var updatedError = currentError;
+ var len = module.exports.getLengthOfTextInTextarea(message);
+
+ if (!currentError && len >= Constants.MAX_POST_LEN) {
+ updatedError = newError;
+ } else if (currentError === newError && len < Constants.MAX_POST_LEN) {
+ updatedError = '';
+ }
+
+ return updatedError;
+};
+
+// Necessary due to issues with textarea max length and pasting newlines
+module.exports.truncateText = function(message) {
+ var lengthDifference = module.exports.getLengthOfTextInTextarea(message) - message.length;
+
+ if (lengthDifference > 0) {
+ return message.substring(0, Constants.MAX_POST_LEN - lengthDifference);
+ }
+
+ return message.substring(0, Constants.MAX_POST_LEN);
+};
+
// Used to get the id of the other user from a DM channel
module.exports.getUserIdFromChannelName = function(channel) {
var ids = channel.name.split('__');
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index e665be6b9..0605e9c3b 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -139,6 +139,9 @@ body.ios {
width: 100%;
padding: 1em 0 0;
position: relative;
+ &.hide-scroll::-webkit-scrollbar {
+ width: 0px !important;
+ }
}
.post-list__table {
display: table;