Commit 999d1553 authored by enahum's avatar enahum Committed by Joram Wilander

PLT-4167 Team Sidebar (#4569)

* PLT-4167 Team Sidebar

* Address feedback from PM

* change route from my_members to members

* bug fixes

* Updating styles for teams sidebar (#4681)

* Added PM changes

* Fix corner cases

* Addressing feedback

* use two different endpoints

* Bug fixes

* Rename model and client functions, using preferences to store last team and channel viewed

* Fix mobile notification count and closing the team sidebar

* unit test, fixed bad merge and retrieve from cached when available

* bug fixes

* use id for last channel in preferences, query optimization

* Updating multi team css (#4830)
parent 3ce2ce9d
......@@ -846,14 +846,21 @@ func setLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
Srv.Store.Channel().SetLastViewedAt(id, c.Session.UserId, newLastViewedAt)
preference := model.Preference{
chanPref := model.Preference{
UserId: c.Session.UserId,
Category: model.PREFERENCE_CATEGORY_LAST,
Category: c.TeamId,
Name: model.PREFERENCE_NAME_LAST_CHANNEL,
Value: id,
}
Srv.Store.Preference().Save(&model.Preferences{preference})
teamPref := model.Preference{
UserId: c.Session.UserId,
Category: model.PREFERENCE_CATEGORY_LAST,
Name: model.PREFERENCE_NAME_LAST_TEAM,
Value: c.TeamId,
}
Srv.Store.Preference().Save(&model.Preferences{teamPref, chanPref})
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_VIEWED, c.TeamId, "", c.Session.UserId, nil)
message.Add("channel_id", id)
......@@ -901,14 +908,21 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
go clearPushNotification(c.Session.UserId, id)
}
preference := model.Preference{
chanPref := model.Preference{
UserId: c.Session.UserId,
Category: model.PREFERENCE_CATEGORY_LAST,
Category: c.TeamId,
Name: model.PREFERENCE_NAME_LAST_CHANNEL,
Value: id,
}
Srv.Store.Preference().Save(&model.Preferences{preference})
teamPref := model.Preference{
UserId: c.Session.UserId,
Category: model.PREFERENCE_CATEGORY_LAST,
Name: model.PREFERENCE_NAME_LAST_TEAM,
Value: c.TeamId,
}
Srv.Store.Preference().Save(&model.Preferences{teamPref, chanPref})
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_VIEWED, c.TeamId, "", c.Session.UserId, nil)
message.Add("channel_id", id)
......
......@@ -824,6 +824,7 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
message.Add("post", post.ToJson())
message.Add("channel_type", channel.Type)
message.Add("channel_display_name", channel.DisplayName)
message.Add("channel_name", channel.Name)
message.Add("sender_name", senderUsername)
message.Add("team_id", team.Id)
......
......@@ -32,6 +32,8 @@ func InitTeam() {
BaseRoutes.Teams.Handle("/get_invite_info", ApiAppHandler(getInviteInfo)).Methods("POST")
BaseRoutes.Teams.Handle("/find_team_by_name", ApiAppHandler(findTeamByName)).Methods("POST")
BaseRoutes.Teams.Handle("/name/{team_name:[A-Za-z0-9\\-]+}", ApiAppHandler(getTeamByName)).Methods("GET")
BaseRoutes.Teams.Handle("/members", ApiUserRequired(getMyTeamMembers)).Methods("GET")
BaseRoutes.Teams.Handle("/unread", ApiUserRequired(getMyTeamsUnread)).Methods("GET")
BaseRoutes.NeedTeam.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET")
BaseRoutes.NeedTeam.Handle("/stats", ApiUserRequired(getTeamStats)).Methods("GET")
......@@ -358,6 +360,11 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError {
return uua.Err
}
// delete the preferences that set the last channel used in the team and other team specific preferences
if result := <-Srv.Store.Preference().DeleteCategory(user.Id, team.Id); result.Err != nil {
return result.Err
}
RemoveAllSessionsForUserId(user.Id)
InvalidateCacheForUser(user.Id)
......@@ -723,6 +730,32 @@ func getTeamByName(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func getMyTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) {
if len(c.Session.TeamMembers) > 0 {
w.Write([]byte(model.TeamMembersToJson(c.Session.TeamMembers)))
} else {
if result := <-Srv.Store.Team().GetTeamsForUser(c.Session.UserId); result.Err != nil {
c.Err = result.Err
return
} else {
data := result.Data.([]*model.TeamMember)
w.Write([]byte(model.TeamMembersToJson(data)))
}
}
}
func getMyTeamsUnread(c *Context, w http.ResponseWriter, r *http.Request) {
teamId := r.URL.Query().Get("id")
if result := <-Srv.Store.Team().GetTeamsUnreadForUser(teamId, c.Session.UserId); result.Err != nil {
c.Err = result.Err
return
} else {
data := result.Data.([]*model.TeamUnread)
w.Write([]byte(model.TeamsUnreadToJson(data)))
}
}
func InviteMembers(team *model.Team, senderName string, invites []string) {
for _, invite := range invites {
if len(invite) > 0 {
......@@ -801,6 +834,10 @@ func updateTeam(c *Context, w http.ResponseWriter, r *http.Request) {
oldTeam.Sanitize()
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_UPDATE_TEAM, "", "", "", nil)
message.Add("team", oldTeam.ToJson())
go Publish(message)
w.Write([]byte(oldTeam.ToJson()))
}
......
......@@ -576,6 +576,41 @@ func TestGetTeamMembers(t *testing.T) {
}
}
func TestGetMyTeamMembers(t *testing.T) {
th := Setup().InitBasic()
if result, err := th.BasicClient.GetMyTeamMembers(); err != nil {
t.Fatal(err)
} else {
members := result.Data.([]*model.TeamMember)
if len(members) == 0 {
t.Fatal("should have results")
}
}
}
func TestGetMyTeamsUnread(t *testing.T) {
th := Setup().InitBasic()
if result, err := th.BasicClient.GetMyTeamsUnread(""); err != nil {
t.Fatal(err)
} else {
members := result.Data.([]*model.TeamUnread)
if len(members) == 0 {
t.Fatal("should have results")
}
}
if result, err := th.BasicClient.GetMyTeamsUnread(th.BasicTeam.Id); err != nil {
t.Fatal(err)
} else {
members := result.Data.([]*model.TeamUnread)
if len(members) != 0 {
t.Fatal("should not have results")
}
}
}
func TestGetTeamMember(t *testing.T) {
th := Setup().InitBasic()
......
......@@ -4863,6 +4863,10 @@
"id": "store.sql_team.get_teams_for_email.app_error",
"translation": "We encountered a problem when looking up teams"
},
{
"id": "store.sql_team.get_unread.app_error",
"translation": "We couldn't get the teams unread messages"
},
{
"id": "store.sql_team.permanent_delete.app_error",
"translation": "We couldn't delete the existing team"
......
......@@ -1752,6 +1752,36 @@ func (c *Client) GetTeamMembers(teamId string, offset int, limit int) (*Result,
}
}
// GetMyTeamMembers will return an array with team member objects that the current user
// is a member of. Must be authenticated.
func (c *Client) GetMyTeamMembers() (*Result, *AppError) {
if r, err := c.DoApiGet("/teams/members", "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), TeamMembersFromJson(r.Body)}, nil
}
}
// GetMyTeamsUnread will return an array with TeamUnread objects that contain the amount of
// unread messages and mentions the current user has for the teams it belongs to.
// An optional team ID can be set to exclude that team from the results. Must be authenticated.
func (c *Client) GetMyTeamsUnread(teamId string) (*Result, *AppError) {
endpoint := "/teams/unread"
if teamId != "" {
endpoint += fmt.Sprintf("?id=%s", url.QueryEscape(teamId))
}
if r, err := c.DoApiGet(endpoint, "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), TeamsUnreadFromJson(r.Body)}, nil
}
}
// GetTeamMember will return a team member object based on the team id and user id provided.
// Must be authenticated.
func (c *Client) GetTeamMember(teamId string, userId string) (*Result, *AppError) {
......
......@@ -33,6 +33,7 @@ const (
PREFERENCE_CATEGORY_LAST = "last"
PREFERENCE_NAME_LAST_CHANNEL = "channel"
PREFERENCE_NAME_LAST_TEAM = "team"
PREFERENCE_CATEGORY_NOTIFICATIONS = "notifications"
PREFERENCE_NAME_EMAIL_INTERVAL = "email_interval"
......
......@@ -16,6 +16,12 @@ type TeamMember struct {
DeleteAt int64 `json:"delete_at"`
}
type TeamUnread struct {
TeamId string `json:"team_id"`
MsgCount int64 `json:"msg_count"`
MentionCount int64 `json:"mention_count"`
}
func (o *TeamMember) ToJson() string {
b, err := json.Marshal(o)
if err != nil {
......@@ -55,6 +61,25 @@ func TeamMembersFromJson(data io.Reader) []*TeamMember {
}
}
func TeamsUnreadToJson(o []*TeamUnread) string {
if b, err := json.Marshal(o); err != nil {
return "[]"
} else {
return string(b)
}
}
func TeamsUnreadFromJson(data io.Reader) []*TeamUnread {
decoder := json.NewDecoder(data)
var o []*TeamUnread
err := decoder.Decode(&o)
if err == nil {
return o
} else {
return nil
}
}
func (o *TeamMember) IsValid() *AppError {
if len(o.TeamId) != 26 {
......
......@@ -18,6 +18,7 @@ const (
WEBSOCKET_EVENT_DIRECT_ADDED = "direct_added"
WEBSOCKET_EVENT_NEW_USER = "new_user"
WEBSOCKET_EVENT_LEAVE_TEAM = "leave_team"
WEBSOCKET_EVENT_UPDATE_TEAM = "update_team"
WEBSOCKET_EVENT_USER_ADDED = "user_added"
WEBSOCKET_EVENT_USER_UPDATED = "user_updated"
WEBSOCKET_EVENT_USER_REMOVED = "user_removed"
......
......@@ -326,3 +326,25 @@ func (s SqlPreferenceStore) Delete(userId, category, name string) StoreChannel {
return storeChannel
}
func (s SqlPreferenceStore) DeleteCategory(userId string, category string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
if _, err := s.GetMaster().Exec(
`DELETE FROM
Preferences
WHERE
UserId = :UserId
AND Category = :Category`, map[string]interface{}{"UserId": userId, "Category": category}); err != nil {
result.Err = model.NewLocAppError("SqlPreferenceStore.DeleteCategory", "store.sql_preference.delete.app_error", nil, err.Error())
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
......@@ -392,3 +392,38 @@ func TestPreferenceDelete(t *testing.T) {
t.Fatal("should've returned no preferences")
}
}
func TestPreferenceDeleteCategory(t *testing.T) {
Setup()
category := model.NewId()
userId := model.NewId()
preference1 := model.Preference{
UserId: userId,
Category: category,
Name: model.NewId(),
Value: "value1a",
}
preference2 := model.Preference{
UserId: userId,
Category: category,
Name: model.NewId(),
Value: "value1a",
}
Must(store.Preference().Save(&model.Preferences{preference1, preference2}))
if prefs := Must(store.Preference().GetAll(userId)).(model.Preferences); len([]model.Preference(prefs)) != 2 {
t.Fatal("should've returned 2 preferences")
}
if result := <-store.Preference().DeleteCategory(userId, category); result.Err != nil {
t.Fatal(result.Err)
}
if prefs := Must(store.Preference().GetAll(userId)).(model.Preferences); len([]model.Preference(prefs)) != 0 {
t.Fatal("should've returned no preferences")
}
}
......@@ -583,6 +583,39 @@ func (s SqlTeamStore) GetTeamsForUser(userId string) StoreChannel {
return storeChannel
}
func (s SqlTeamStore) GetTeamsUnreadForUser(teamId, userId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
var members []*model.TeamUnread
_, err := s.GetReplica().Select(&members,
`SELECT
Channels.TeamId,
SUM(Channels.TotalMsgCount - ChannelMembers.MsgCount) as MsgCount,
SUM(ChannelMembers.MentionCount) as MentionCount
FROM
Channels,
ChannelMembers
WHERE
Channels.Id = ChannelMembers.ChannelId AND
ChannelMembers.UserId = :UserId AND Channels.TeamId != :TeamId
GROUP BY
Channels.TeamId`, map[string]interface{}{"UserId": userId, "TeamId": teamId})
if err != nil {
result.Err = model.NewLocAppError("SqlTeamStore.GetTeamsUnreadForUser", "store.sql_team.get_unread.app_error", nil, "userId="+userId+" "+err.Error())
} else {
result.Data = members
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlTeamStore) RemoveMember(teamId string, userId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
......
......@@ -529,3 +529,50 @@ func TestTeamStoreMemberCount(t *testing.T) {
}
}
}
func TestMyTeamMembersUnread(t *testing.T) {
Setup()
teamId1 := model.NewId()
teamId2 := model.NewId()
uid := model.NewId()
m1 := &model.TeamMember{TeamId: teamId1, UserId: uid}
m2 := &model.TeamMember{TeamId: teamId2, UserId: uid}
Must(store.Team().SaveMember(m1))
Must(store.Team().SaveMember(m2))
c1 := &model.Channel{TeamId: m1.TeamId, Name: model.NewId(), DisplayName: "Town Square", Type: model.CHANNEL_OPEN}
Must(store.Channel().Save(c1))
c2 := &model.Channel{TeamId: m2.TeamId, Name: model.NewId(), DisplayName: "Town Square", Type: model.CHANNEL_OPEN}
Must(store.Channel().Save(c2))
cm1 := &model.ChannelMember{ChannelId: c1.Id, UserId: m1.UserId, NotifyProps: model.GetDefaultChannelNotifyProps()}
Must(store.Channel().SaveMember(cm1))
cm2 := &model.ChannelMember{ChannelId: c2.Id, UserId: m2.UserId, NotifyProps: model.GetDefaultChannelNotifyProps()}
Must(store.Channel().SaveMember(cm2))
if r1 := <-store.Team().GetTeamsUnreadForUser("", uid); r1.Err != nil {
t.Fatal(r1.Err)
} else {
ms := r1.Data.([]*model.TeamUnread)
if len(ms) != 2 {
t.Fatal("Should be the unreads for all the teams")
}
}
if r2 := <-store.Team().GetTeamsUnreadForUser(teamId1, uid); r2.Err != nil {
t.Fatal(r2.Err)
} else {
ms := r2.Data.([]*model.TeamUnread)
if len(ms) != 1 {
t.Fatal("Should be the unreads for just one team")
}
}
if r1 := <-store.Team().RemoveAllMembersByUser(uid); r1.Err != nil {
t.Fatal(r1.Err)
}
}
......@@ -74,6 +74,7 @@ type TeamStore interface {
GetTotalMemberCount(teamId string) StoreChannel
GetActiveMemberCount(teamId string) StoreChannel
GetTeamsForUser(userId string) StoreChannel
GetTeamsUnreadForUser(teamId, userId string) StoreChannel
RemoveMember(teamId string, userId string) StoreChannel
RemoveAllMembersByTeam(teamId string) StoreChannel
RemoveAllMembersByUser(userId string) StoreChannel
......@@ -270,6 +271,7 @@ type PreferenceStore interface {
GetCategory(userId string, category string) StoreChannel
GetAll(userId string) StoreChannel
Delete(userId, category, name string) StoreChannel
DeleteCategory(userId string, category string) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel
IsFeatureEnabled(feature, userId string) StoreChannel
}
......
......@@ -43,6 +43,7 @@ export function emitChannelClickEvent(channel) {
);
}
function switchToChannel(chan) {
const channelMember = ChannelStore.getMyMember(chan.id);
const getMyChannelMembersPromise = AsyncClient.getChannelMember(chan.id, UserStore.getCurrentId());
getMyChannelMembersPromise.then(() => {
......@@ -56,6 +57,9 @@ export function emitChannelClickEvent(channel) {
type: ActionTypes.CLICK_CHANNEL,
name: chan.name,
id: chan.id,
team_id: chan.team_id,
total_msg_count: chan.total_msg_count,
channelMember,
prev: ChannelStore.getCurrentId()
});
}
......@@ -443,7 +447,7 @@ export function viewLoggedIn() {
PostStore.clearPendingPosts();
}
var lastTimeTypingSent = 0;
let lastTimeTypingSent = 0;
export function emitLocalUserTypingEvent(channelId, parentId) {
const t = Date.now();
if ((t - lastTimeTypingSent) > Constants.UPDATE_TYPING_MS) {
......@@ -534,3 +538,35 @@ export function emitBrowserFocus(focus) {
focus
});
}
export function redirectUserToDefaultTeam() {
const teams = TeamStore.getAll();
const teamMembers = TeamStore.getMyTeamMembers();
let teamId = PreferenceStore.get('last', 'team');
if (!teams[teamId] && teamMembers.length > 0) {
let myTeams = [];
for (const index in teamMembers) {
if (teamMembers.hasOwnProperty(index)) {
const teamMember = teamMembers[index];
myTeams.push(teams[teamMember.team_id]);
}
}
if (myTeams.length > 0) {
myTeams = myTeams.sort((a, b) => a.display_name.localeCompare(b.display_name));
teamId = myTeams[0].id;
}
}
if (teams[teamId]) {
const channelId = PreferenceStore.get(teamId, 'channel');
let channel = ChannelStore.getChannelById(channelId);
if (!channel) {
channel = 'town-square';
}
browserHistory.push(`/${teams[teamId].name}/channels/${channel}`);
} else {
browserHistory.push('/select_team');
}
}
......@@ -18,23 +18,30 @@ const ActionTypes = Constants.ActionTypes;
const Preferences = Constants.Preferences;
export function handleNewPost(post, msg) {
const teamId = TeamStore.getCurrentId();
if (ChannelStore.getCurrentId() === post.channel_id) {
if (window.isActive) {
AsyncClient.updateLastViewedAt(null, false);
} else {
AsyncClient.getChannel(post.channel_id);
}
} else if (msg && (TeamStore.getCurrentId() === msg.data.team_id || msg.data.channel_type === Constants.DM_CHANNEL)) {
} else if (msg && (teamId === msg.data.team_id || msg.data.channel_type === Constants.DM_CHANNEL)) {
if (Client.teamId) {
AsyncClient.getChannel(post.channel_id);
}
}
var websocketMessageProps = null;
let websocketMessageProps = null;
if (msg) {
websocketMessageProps = msg.data;
}
const myTeams = TeamStore.getMyTeamMembers();
if (msg.data.team_id !== teamId && myTeams.filter((m) => m.team_id === msg.data.team_id).length) {
AsyncClient.getMyTeamsUnread(teamId);
}
if (post.root_id && PostStore.getPost(post.channel_id, post.root_id) == null) {
Client.getPost(
post.channel_id,
......
......@@ -6,6 +6,7 @@ import $ from 'jquery';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import PostStore from 'stores/post_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import BrowserStore from 'stores/browser_store.jsx';
import ErrorStore from 'stores/error_store.jsx';
......@@ -122,6 +123,10 @@ function handleEvent(msg) {
handleLeaveTeamEvent(msg);
break;
case SocketEvents.UPDATE_TEAM:
handleUpdateTeamEvent(msg);
break;
case SocketEvents.USER_ADDED:
handleUserAddedEvent(msg);
break;
......@@ -229,21 +234,37 @@ function handleNewUserEvent(msg) {
AsyncClient.getUser(msg.data.user_id);
AsyncClient.getChannelStats();
loadProfilesAndTeamMembersForDMSidebar();
if (msg.data.user_id === UserStore.getCurrentId()) {
AsyncClient.getMyTeamMembers();
}
}
function handleLeaveTeamEvent(msg) {
if (UserStore.getCurrentId() === msg.data.user_id) {
TeamStore.removeMyTeamMember(msg.data.team_id);
// if they are on the team being removed redirect them to the root
// if they are on the team being removed redirect them to default team
if (TeamStore.getCurrentId() === msg.data.team_id) {
TeamStore.setCurrentId('');
Client.setTeamId('');
browserHistory.push('/');
PreferenceStore.deletePreference({
category: 'last',
name: 'team',
value: msg.data.team_id
});
GlobalActions.redirectUserToDefaultTeam();
}
} else {
UserStore.removeProfileFromTeam(msg.data.team_id, msg.data.user_id);
TeamStore.removeMemberInTeam(msg.data.team_id, msg.data.user_id);
}
}
function handleUpdateTeamEvent(msg) {
TeamStore.updateTeam(msg.data.team);
}
function handleDirectAddedEvent(msg) {
AsyncClient.getChannel(msg.broadcast.channel_id);
loadProfilesAndTeamMembersForDMSidebar();
......
......@@ -598,6 +598,30 @@ export default class Client {
end(this.handleResponse.bind(this, 'getTeamMember', success, error));
}
getMyTeamMembers(success, error) {
request.
get(`${this.getTeamsRoute()}/members`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getMyTeamMembers', success, error));
}
getMyTeamsUnread(teamId, success, error) {
let url = `${this.getTeamsRoute()}/unread`;
if (teamId) {
url