diff --git a/api/channel.go b/api/channel.go index 2a5b6f8b02..3fef273e5a 100644 --- a/api/channel.go +++ b/api/channel.go @@ -44,6 +44,7 @@ func InitChannel() { BaseRoutes.NeedChannel.Handle("/add", ApiUserRequired(addMember)).Methods("POST") BaseRoutes.NeedChannel.Handle("/remove", ApiUserRequired(removeMember)).Methods("POST") BaseRoutes.NeedChannel.Handle("/update_last_viewed_at", ApiUserRequired(updateLastViewedAt)).Methods("POST") + BaseRoutes.NeedChannel.Handle("/set_last_viewed_at", ApiUserRequired(setLastViewedAt)).Methods("POST") } func createChannel(c *Context, w http.ResponseWriter, r *http.Request) { @@ -791,6 +792,34 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } +func setLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id := params["channel_id"] + + data := model.StringInterfaceFromJson(r.Body) + newLastViewedAt := int64(data["last_viewed_at"].(float64)) + + Srv.Store.Channel().SetLastViewedAt(id, c.Session.UserId, newLastViewedAt) + + preference := model.Preference{ + UserId: c.Session.UserId, + Category: model.PREFERENCE_CATEGORY_LAST, + Name: model.PREFERENCE_NAME_LAST_CHANNEL, + Value: id, + } + + Srv.Store.Preference().Save(&model.Preferences{preference}) + + message := model.NewWebSocketEvent(c.TeamId, id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_VIEWED) + message.Add("channel_id", id) + + go Publish(message) + + result := make(map[string]string) + result["id"] = id + w.Write([]byte(model.MapToJson(result))) +} + func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) id := params["channel_id"] diff --git a/i18n/en.json b/i18n/en.json index 9ec9f393de..961ddc50c8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -557,7 +557,7 @@ }, { "id": "api.command_shortcuts.list", - "translation": "### Keyboard Shortcuts\n\n#### Navigation\n\nALT+UP: Previous channel or direct message in left hand sidebar\nALT+DOWN: Next channel or direct message in left hand sidebar\nALT+SHIFT+UP: Previous channel or direct message in left hand sidebar with unread messages\nALT+SHIFT+DOWN: Next channel or direct message in left hand sidebar with unread messages\nCTRL/CMD+K: Open a quick channel switcher dialog\nCTRL/CMD+SHIFT+A: Open account settings\nCTRL/CMD+SHIFT+M: Open recent mentions\n\n#### Files\n\nCTRL/CMD+U: Upload file(s)\n\n#### Messages\n\nCTRL/CMD+UP (in empty input field): Reprint the previous message or slash command you entered\nCTRL/CMD+DOWN (in empty input field): Reprint the next message or slash command you entered\nUP (in empty input field): Edit your last message in the current channel\n@[character]+TAB: Autocomplete @username beginning with [character]\n:[character]+TAB: Autocomplete emoji beginning with [character]\n\n#### Built-in Browser Commands\n\nALT+LEFT/CMD+[: Previous channel in your history\nALT+RIGHT/CMD+]: Next channel in your history\nCTRL/CMD+PLUS: Increase font size (zoom in)\nCTRL/CMD+MINUS: Decrease font size (zoom out)\nSHIFT+UP (in input field): Highlight text to the previous line\nSHIFT+DOWN (in input field): Highlight text to the next line\nSHIFT+ENTER (in input field): Create a new line\n" + "translation": "### Keyboard Shortcuts\n\n#### Navigation\n\nALT+UP: Previous channel or direct message in left hand sidebar\nALT+DOWN: Next channel or direct message in left hand sidebar\nALT+SHIFT+UP: Previous channel or direct message in left hand sidebar with unread messages\nALT+SHIFT+DOWN: Next channel or direct message in left hand sidebar with unread messages\nCTRL/CMD+K: Open a quick channel switcher dialog\nCTRL/CMD+SHIFT+A: Open account settings\nCTRL/CMD+SHIFT+M: Open recent mentions\n\n#### Files\n\nCTRL/CMD+U: Upload file(s)\n\n#### Messages\n\nALT+Click: Set message as unread\nESC: Set all messages in channel as read\nCTRL/CMD+UP (in empty input field): Reprint the previous message or slash command you entered\nCTRL/CMD+DOWN (in empty input field): Reprint the next message or slash command you entered\nUP (in empty input field): Edit your last message in the current channel\n@[character]+TAB: Autocomplete @username beginning with [character]\n:[character]+TAB: Autocomplete emoji beginning with [character]\n\n#### Built-in Browser Commands\n\nALT+LEFT/CMD+[: Previous channel in your history\nALT+RIGHT/CMD+]: Next channel in your history\nCTRL/CMD+PLUS: Increase font size (zoom in)\nCTRL/CMD+MINUS: Decrease font size (zoom out)\nSHIFT+UP (in input field): Highlight text to the previous line\nSHIFT+DOWN (in input field): Highlight text to the next line\nSHIFT+ENTER (in input field): Create a new line\n" }, { "id": "api.command_shortcuts.name", @@ -3515,6 +3515,10 @@ "id": "store.sql_channel.save_member.save.app_error", "translation": "We couldn't save the channel member" }, + { + "id": "store.sql_channel.set_last_viewed_at.app_error", + "translation": "We couldn't set the last viewed at time" + }, { "id": "store.sql_channel.update.app_error", "translation": "We couldn't update the channel" diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index e5e0aa8ba1..2b356d0de5 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -856,6 +856,58 @@ func (s SqlChannelStore) CheckOpenChannelPermissions(teamId string, channelId st return storeChannel } +func (s SqlChannelStore) SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var query string + + if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + query = `UPDATE + ChannelMembers + SET + MentionCount = 0, + MsgCount = Channels.TotalMsgCount - (SELECT COUNT(*) + FROM Posts + WHERE ChannelId = :ChannelId + AND CreateAt > :NewLastViewedAt), + LastViewedAt = :NewLastViewedAt + FROM + Channels + WHERE + Channels.Id = ChannelMembers.ChannelId + AND UserId = :UserId + AND ChannelId = :ChannelId` + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + query = `UPDATE + ChannelMembers, Channels + SET + ChannelMembers.MentionCount = 0, + ChannelMembers.MsgCount = Channels.TotalMsgCount - (SELECT COUNT(*) + FROM Posts + WHERE ChannelId = :ChannelId + AND CreateAt > :NewLastViewedAt), + ChannelMembers.LastViewedAt = :NewLastViewedAt + WHERE + Channels.Id = ChannelMembers.ChannelId + AND UserId = :UserId + AND ChannelId = :ChannelId` + } + + _, err := s.GetMaster().Exec(query, map[string]interface{}{"ChannelId": channelId, "UserId": userId, "NewLastViewedAt": newLastViewedAt}) + if err != nil { + result.Err = model.NewLocAppError("SqlChannelStore.SetLastViewedAt", "store.sql_channel.set_last_viewed_at.app_error", nil, "channel_id="+channelId+", user_id="+userId+", "+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlChannelStore) UpdateLastViewedAt(channelId string, userId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/store.go b/store/store.go index f576cc2ab3..445de440a5 100644 --- a/store/store.go +++ b/store/store.go @@ -99,6 +99,7 @@ type ChannelStore interface { CheckOpenChannelPermissions(teamId string, channelId string) StoreChannel CheckPermissionsToByName(teamId string, channelName string, userId string) StoreChannel UpdateLastViewedAt(channelId string, userId string) StoreChannel + SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel IncrementMentionCount(channelId string, userId string) StoreChannel AnalyticsTypeCount(teamId string, channelType string) StoreChannel ExtraUpdateByUser(userId string, time int64) StoreChannel diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index 9e5ecb03b4..f8bc615382 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -5,6 +5,8 @@ import {browserHistory} from 'react-router/es6'; import * as Utils from 'utils/utils.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; import Client from 'utils/web_client.jsx'; export function goToChannel(channel) { @@ -24,3 +26,13 @@ export function goToChannel(channel) { export function executeCommand(channelId, message, suggest, success, error) { Client.executeCommand(channelId, message, suggest, success, error); } + +export function setChannelAsRead(channelIdParam) { + const channelId = channelIdParam || ChannelStore.getCurrentId(); + AsyncClient.updateLastViewedAt(); + ChannelStore.resetCounts(channelId); + ChannelStore.emitChange(); + if (channelId === ChannelStore.getCurrentId()) { + ChannelStore.emitLastViewed(Number.MAX_VALUE, false); + } +} diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index 866ae58880..a6b464a245 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -6,7 +6,9 @@ import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import PostStore from 'stores/post_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; +import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -62,3 +64,53 @@ export function handleNewPost(post, msg) { websocketMessageProps }); } + +export function setUnreadPost(channelId, postId) { + let lastViewed = 0; + let ownNewMessage = false; + const post = PostStore.getPost(channelId, postId); + const posts = PostStore.getVisiblePosts(channelId).posts; + var currentUsedId = UserStore.getCurrentId(); + if (currentUsedId === post.user_id || PostUtils.isSystemMessage(post)) { + for (const otherPostId in posts) { + if (lastViewed < posts[otherPostId].create_at && currentUsedId !== posts[otherPostId].user_id && !PostUtils.isSystemMessage(posts[otherPostId])) { + lastViewed = posts[otherPostId].create_at; + } + } + if (lastViewed === 0) { + lastViewed = Number.MAX_VALUE; + } else if (lastViewed > post.create_at) { + lastViewed = post.create_at - 1; + ownNewMessage = true; + } else { + lastViewed -= 1; + } + } else { + lastViewed = post.create_at - 1; + } + + if (lastViewed === Number.MAX_VALUE) { + AsyncClient.updateLastViewedAt(); + ChannelStore.resetCounts(ChannelStore.getCurrentId()); + ChannelStore.emitChange(); + } else { + let unreadPosts = 0; + for (const otherPostId in posts) { + if (posts[otherPostId].create_at > lastViewed) { + unreadPosts += 1; + } + } + const member = ChannelStore.getMember(channelId); + const channel = ChannelStore.get(channelId); + member.last_viewed_at = lastViewed; + member.msg_count = channel.total_msg_count - unreadPosts; + member.mention_count = 0; + ChannelStore.setChannelMember(member); + ChannelStore.setUnreadCount(channelId); + AsyncClient.setLastViewedAt(lastViewed, channelId); + } + + if (channelId === ChannelStore.getCurrentId()) { + ChannelStore.emitLastViewed(lastViewed, ownNewMessage); + } +} diff --git a/webapp/components/edit_post_modal.jsx b/webapp/components/edit_post_modal.jsx index 4bd23a26d9..1ddaee535f 100644 --- a/webapp/components/edit_post_modal.jsx +++ b/webapp/components/edit_post_modal.jsx @@ -37,6 +37,11 @@ class EditPostModal extends React.Component { this.handleEditPostEvent = this.handleEditPostEvent.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.onPreferenceChange = this.onPreferenceChange.bind(this); + this.onModalHidden = this.onModalHidden.bind(this); + this.onModalShow = this.onModalShow.bind(this); + this.onModalShown = this.onModalShown.bind(this); + this.onModalHide = this.onModalHide.bind(this); + this.onModalKeyDown = this.onModalKeyDown.bind(this); this.state = {editText: '', originalText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: '', typing: false}; } @@ -116,46 +121,55 @@ class EditPostModal extends React.Component { ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter') }); } - componentDidMount() { - var self = this; - - $(ReactDOM.findDOMNode(this.refs.modal)).on('hidden.bs.modal', () => { - self.setState({editText: '', originalText: '', title: '', channel_id: '', post_id: '', comments: 0, refocusId: '', error: '', typing: false}); + onModalHidden() { + this.setState({editText: '', originalText: '', title: '', channel_id: '', post_id: '', comments: 0, refocusId: '', error: '', typing: false}); + } + onModalShow(e) { + var button = e.relatedTarget; + if (!button) { + return; + } + this.setState({ + editText: $(button).attr('data-message'), + originalText: $(button).attr('data-message'), + title: $(button).attr('data-title'), + channel_id: $(button).attr('data-channelid'), + post_id: $(button).attr('data-postid'), + comments: $(button).attr('data-comments'), + refocusId: $(button).attr('data-refocusid'), + typing: false }); - - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', (e) => { - var button = e.relatedTarget; - if (!button) { - return; - } - self.setState({ - editText: $(button).attr('data-message'), - originalText: $(button).attr('data-message'), - title: $(button).attr('data-title'), - channel_id: $(button).attr('data-channelid'), - post_id: $(button).attr('data-postid'), - comments: $(button).attr('data-comments'), - refocusId: $(button).attr('data-refocusid'), - typing: false + } + onModalShown() { + this.refs.editbox.focus(); + } + onModalHide() { + if (this.state.refocusId !== '') { + setTimeout(() => { + $(this.state.refocusId).get(0).focus(); }); - }); - - $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', () => { - self.refs.editbox.focus(); - }); - - $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', () => { - if (self.state.refocusId !== '') { - setTimeout(() => { - $(self.state.refocusId).get(0).focus(); - }); - } - }); - + } + } + onModalKeyDown(e) { + if (e.which === Constants.KeyCodes.ESCAPE) { + e.stopPropagation(); + } + } + componentDidMount() { + $(this.refs.modal).on('hidden.bs.modal', this.onModalHidden); + $(this.refs.modal).on('show.bs.modal', this.onModalShow); + $(this.refs.modal).on('shown.bs.modal', this.onModalShown); + $(this.refs.modal).on('hide.bs.modal', this.onModalHide); + $(this.refs.modal).on('keydown', this.onModalKeyDown); PostStore.addEditPostListener(this.handleEditPostEvent); PreferenceStore.addChangeListener(this.onPreferenceChange); } componentWillUnmount() { + $(this.refs.modal).off('hidden.bs.modal', this.onModalHidden); + $(this.refs.modal).off('show.bs.modal', this.onModalShow); + $(this.refs.modal).off('shown.bs.modal', this.onModalShown); + $(this.refs.modal).off('hide.bs.modal', this.onModalHide); + $(this.refs.modal).off('keydown', this.onModalKeyDown); PostStore.removeEditPostListner(this.handleEditPostEvent); PreferenceStore.removeChangeListener(this.onPreferenceChange); } diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx index 21d335a516..ff443e3552 100644 --- a/webapp/components/post_view/components/post.jsx +++ b/webapp/components/post_view/components/post.jsx @@ -10,6 +10,7 @@ const ActionTypes = Constants.ActionTypes; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import * as PostActions from 'actions/post_actions.jsx'; import React from 'react'; @@ -20,6 +21,7 @@ export default class Post extends React.Component { this.handleCommentClick = this.handleCommentClick.bind(this); this.handleDropdownOpened = this.handleDropdownOpened.bind(this); this.forceUpdateInfo = this.forceUpdateInfo.bind(this); + this.handlePostClick = this.handlePostClick.bind(this); this.state = { dropdownOpened: false @@ -47,6 +49,12 @@ export default class Post extends React.Component { this.refs.info.forceUpdate(); this.refs.header.forceUpdate(); } + handlePostClick(e) { + if (e.altKey) { + e.preventDefault(); + PostActions.setUnreadPost(this.props.post.channel_id, this.props.post.id); + } + } shouldComponentUpdate(nextProps, nextState) { if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; @@ -213,6 +221,7 @@ export default class Post extends React.Component {