diff options
35 files changed, 1726 insertions, 898 deletions
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/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 cbba83af6..ac9adf358 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/team.go b/api/team.go index a331e9e34..8587a6de4 100644 --- a/api/team.go +++ b/api/team.go @@ -283,10 +283,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/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/store/sql_post_store.go b/store/sql_post_store.go index 479caf838..4ea28507b 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,62 @@ 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 100) + UNION + (SELECT + * + FROM + Posts + WHERE + Id + IN + (SELECT * FROM (SELECT + RootId + FROM + Posts + WHERE + UpdateAt > :Time + AND ChannelId = :ChannelId + LIMIT 100) 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) + 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 336a20d98..8854fb5c4 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/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/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/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..4b0b90dc7 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,58 @@ 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; + } + + 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..f0cf17446 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -765,7 +765,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 +987,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; |