diff --git a/.changeset/sidebar-teams-channels-merge.md b/.changeset/sidebar-teams-channels-merge.md new file mode 100644 index 0000000000000..704fb2d77e665 --- /dev/null +++ b/.changeset/sidebar-teams-channels-merge.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Adds two sidebar grouping user preferences: + +- `sidebarGroupTeamsAndChannels`: merges the Teams and Channels sidebar groups into a single "Teams and channels" category when grouping by type is enabled. +- `sidebarGroupUnlistedInConversations`: routes rooms whose group is not part of the visible sidebar sections into "Conversations" instead of dropping them from the sidebar. diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx index ea8ca667ec079..7c66bdfb711c2 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx +++ b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx @@ -64,18 +64,24 @@ const emptyArr: any[] = []; const getWrapperSettings = ({ sidebarGroupByType = false, + sidebarGroupTeamsAndChannels = false, + sidebarGroupUnlistedInConversations = false, sidebarShowFavorites = false, isDiscussionEnabled = false, sidebarShowUnread = false, + sidebarSectionsOrder = undefined, fakeRoom = undefined, }: { sidebarGroupByType?: boolean; + sidebarGroupTeamsAndChannels?: boolean; + sidebarGroupUnlistedInConversations?: boolean; sidebarShowFavorites?: boolean; isDiscussionEnabled?: boolean; sidebarShowUnread?: boolean; + sidebarSectionsOrder?: string[]; fakeRoom?: SubscriptionWithRoom; -}) => - mockAppRoot() +}) => { + let root = mockAppRoot() .wrap((children) => ( { const { result: { @@ -161,6 +176,92 @@ it('should return groupsList with "Teams" if sidebarGroupByType is enabled and r expect(groupsCount[teamsIndex]).toEqual(teams.length); }); +it('should merge teams and channels into "Teams_and_channels" when sidebarGroupTeamsAndChannels is enabled', async () => { + const { + result: { + current: { groupsList, groupsCount }, + }, + } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + wrapper: getWrapperSettings({ sidebarGroupByType: true, sidebarGroupTeamsAndChannels: true }).build(), + }); + + expect(groupsList).toContain('Teams_and_channels'); + expect(groupsList).not.toContain('Teams'); + expect(groupsList).not.toContain('Channels'); + + const mergedIndex = groupsList.indexOf('Teams_and_channels'); + // merged group holds every team plus every channel, so it is strictly larger than teams alone + expect(groupsCount[mergedIndex]).toBeGreaterThan(teams.length); + expect(groupsCount.reduce((a, b) => a + b, 0)).toBe(fakeRooms.length); +}); + +it('should keep "Teams" and "Channels" separate when sidebarGroupTeamsAndChannels is disabled', async () => { + const { + result: { + current: { groupsList }, + }, + } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + wrapper: getWrapperSettings({ sidebarGroupByType: true, sidebarGroupTeamsAndChannels: false }).build(), + }); + + expect(groupsList).toContain('Teams'); + expect(groupsList).toContain('Channels'); + expect(groupsList).not.toContain('Teams_and_channels'); +}); + +it('should render the merged group even when the saved sidebarSectionsOrder predates it', async () => { + const legacyOrder = ['Unread', 'Favorites', 'Teams', 'Discussions', 'Channels', 'Direct_Messages', 'Conversations']; + const { + result: { + current: { groupsList }, + }, + } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + wrapper: getWrapperSettings({ sidebarGroupByType: true, sidebarGroupTeamsAndChannels: true }) + .withUserPreference('sidebarSectionsOrder', legacyOrder) + .build(), + }); + + expect(groupsList).toContain('Teams_and_channels'); +}); + +it('should drop rooms whose group is not in the visible sections (default behavior)', async () => { + // section order without "Direct_Messages": direct messages have nowhere to render + const orderWithoutDM = ['Unread', 'Drafts', 'Favorites', 'Teams', 'Discussions', 'Channels', 'Teams_and_channels', 'Conversations']; + const { + result: { + current: { roomList, groupsList }, + }, + } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + wrapper: getWrapperSettings({ sidebarGroupByType: true, sidebarSectionsOrder: orderWithoutDM }).build(), + }); + + expect(groupsList).not.toContain('Direct_Messages'); + expect(groupsList).not.toContain('Conversations'); + expect(roomList).not.toContain(directRooms[0]); +}); + +it('should route rooms of an unlisted group into "Conversations" when sidebarGroupUnlistedInConversations is enabled', async () => { + const orderWithoutDM = ['Unread', 'Drafts', 'Favorites', 'Teams', 'Discussions', 'Channels', 'Teams_and_channels', 'Conversations']; + const { + result: { + current: { roomList, groupsList, groupsCount }, + }, + } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + wrapper: getWrapperSettings({ + sidebarGroupByType: true, + sidebarGroupUnlistedInConversations: true, + sidebarSectionsOrder: orderWithoutDM, + }).build(), + }); + + expect(groupsList).toContain('Conversations'); + expect(groupsList).not.toContain('Direct_Messages'); + expect(roomList).toContain(directRooms[0]); + + const conversationsIndex = groupsList.indexOf('Conversations'); + expect(groupsCount[conversationsIndex]).toBeGreaterThanOrEqual(directRooms.length); +}); + it('should return groupsList with "Favorites" if sidebarShowFavorites is enabled', async () => { const { result: { diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index eda1362011b7b..57f616f4a6341 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -25,10 +25,16 @@ const order = [ 'Teams', 'Discussions', 'Channels', + 'Teams_and_channels', 'Direct_Messages', 'Conversations', ] as const; +// Type groups that hold regular conversations and can be routed into "Conversations" +// when they are not part of the visible sidebar sections. Override buckets (Unread, +// Drafts, Favorites) and system groups (omnichannel, incoming calls) are excluded. +const routableGroups: string[] = ['Teams', 'Discussions', 'Channels', 'Teams_and_channels', 'Direct_Messages']; + type useRoomListReturnType = { roomList: Array; groupsCount: number[]; @@ -41,6 +47,8 @@ type useRoomListReturnType = { export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }): useRoomListReturnType => { const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const sidebarGroupTeamsAndChannels = useUserPreference('sidebarGroupTeamsAndChannels'); + const groupUnlistedInConversations = useUserPreference('sidebarGroupUnlistedInConversations'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); const sidebarDrafts = useFeaturePreview('sidebarDrafts'); const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; @@ -134,17 +142,56 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) favoritesEnabled && favorite.size && groups.set('Favorites', favorite); - sidebarGroupByType && team.size && groups.set('Teams', team); + const mergeTeamsAndChannels = sidebarGroupByType && sidebarGroupTeamsAndChannels; + + sidebarGroupByType && !mergeTeamsAndChannels && team.size && groups.set('Teams', team); sidebarGroupByType && isDiscussionEnabled && discussion.size && groups.set('Discussions', discussion); - sidebarGroupByType && channels.size && groups.set('Channels', channels); + sidebarGroupByType && !mergeTeamsAndChannels && channels.size && groups.set('Channels', channels); + + if (mergeTeamsAndChannels) { + const teamsAndChannels = new Set([...team, ...channels]); + teamsAndChannels.size && groups.set('Teams_and_channels', teamsAndChannels); + } sidebarGroupByType && direct.size && groups.set('Direct_Messages', direct); !sidebarGroupByType && groups.set('Conversations', conversation); - const { groupsCount, groupsList, roomList, groupedUnreadInfo } = sidebarOrder.reduce( + // Users with a `sidebarSectionsOrder` saved before this group existed won't have the + // 'Teams_and_channels' key, so the merged group would never render. Inject it after 'Channels'. + // Build the render order. Users with a `sidebarSectionsOrder` saved before the + // 'Teams_and_channels' group existed won't have the key, so inject it after 'Channels'. + const effectiveOrder: string[] = [...sidebarOrder]; + if (!effectiveOrder.includes('Teams_and_channels')) { + const channelsIndex = effectiveOrder.indexOf('Channels'); + effectiveOrder.splice(channelsIndex === -1 ? effectiveOrder.length : channelsIndex + 1, 0, 'Teams_and_channels'); + } + + // When enabled, rooms belonging to a routable group that is not part of the visible + // sections would otherwise vanish from the sidebar. Route them into "Conversations" + // instead of dropping them. + if (sidebarGroupByType && groupUnlistedInConversations) { + const unlisted = new Set(); + for (const [key, set] of groups) { + if (!routableGroups.includes(key) || effectiveOrder.includes(key)) { + continue; + } + set.forEach((room) => unlisted.add(room as SubscriptionWithRoom)); + groups.delete(key); + } + + if (unlisted.size) { + const existing = groups.get('Conversations'); + groups.set('Conversations', existing ? new Set([...existing, ...unlisted]) : unlisted); + if (!effectiveOrder.includes('Conversations')) { + effectiveOrder.push('Conversations'); + } + } + } + + const { groupsCount, groupsList, roomList, groupedUnreadInfo } = effectiveOrder.reduce( (acc, key) => { const value = groups.get(key); @@ -208,6 +255,8 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) sidebarShowUnread, favoritesEnabled, sidebarGroupByType, + sidebarGroupTeamsAndChannels, + groupUnlistedInConversations, isDiscussionEnabled, sidebarOrder, collapsedGroups, diff --git a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts index b3d6709cee40e..9aa48273de1c1 100644 --- a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts +++ b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts @@ -35,6 +35,8 @@ export type AccountPreferencesData = { sidebarViewMode?: string; sidebarDisplayAvatar?: boolean; sidebarGroupByType?: boolean; + sidebarGroupTeamsAndChannels?: boolean; + sidebarGroupUnlistedInConversations?: boolean; masterVolume?: number; notificationsSoundVolume?: number; voipRingerVolume?: number; diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index d7582df642a34..8ddb02c445fcb 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -45,6 +45,8 @@ type UserPreferences = { sidebarViewMode: string; sidebarDisplayAvatar: boolean; sidebarGroupByType: boolean; + sidebarGroupTeamsAndChannels: boolean; + sidebarGroupUnlistedInConversations: boolean; muteFocusedConversations: boolean; dontAskAgainList: { action: string; label: string }[]; themeAppearence: ThemePreference; @@ -119,6 +121,8 @@ export const saveUserPreferences = async (settings: Partial, us sidebarViewMode: Match.Optional(String), sidebarDisplayAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), + sidebarGroupTeamsAndChannels: Match.Optional(Boolean), + sidebarGroupUnlistedInConversations: Match.Optional(Boolean), muteFocusedConversations: Match.Optional(Boolean), themeAppearence: Match.Optional(String), fontSize: Match.Optional(String), diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index f62a8ac552568..47d06bb2d1b31 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -536,6 +536,16 @@ export const createAccountSettings = () => public: true, i18nLabel: 'Group_by_Type', }); + await this.add('Accounts_Default_User_Preferences_sidebarGroupTeamsAndChannels', false, { + type: 'boolean', + public: true, + i18nLabel: 'Group_Teams_and_Channels', + }); + await this.add('Accounts_Default_User_Preferences_sidebarGroupUnlistedInConversations', false, { + type: 'boolean', + public: true, + i18nLabel: 'Group_Unlisted_In_Conversations', + }); await this.add('Accounts_Default_User_Preferences_themeAppearence', 'auto', { type: 'select', values: [ @@ -755,6 +765,7 @@ export const createAccountSettings = () => 'Teams', 'Discussions', 'Channels', + 'Teams_and_channels', 'Direct_Messages', 'Conversations', ]; diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 1196c95e24a49..f04df6a0eecb8 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -188,6 +188,8 @@ describe('miscellaneous', () => { 'sidebarViewMode', 'sidebarDisplayAvatar', 'sidebarGroupByType', + 'sidebarGroupTeamsAndChannels', + 'sidebarGroupUnlistedInConversations', 'sidebarSectionsOrder', 'muteFocusedConversations', 'notifyCalendarEvents', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 99f7b4b067fb8..83b9f09513502 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2512,6 +2512,8 @@ "Group": "Group", "Group_by": "Group by", "Group_by_Type": "Group by Type", + "Group_Teams_and_Channels": "Group teams and channels together", + "Group_Unlisted_In_Conversations": "Group unlisted rooms in Conversations", "Group_discussions": "Group discussions", "Group_favorites": "Group favorites", "Group_mentions_disabled_x_members": "Group mentions `@all` and `@here` have been disabled for rooms with more than {{total}} members.", @@ -5228,6 +5230,7 @@ "Team_voice_call": "Team voice calls", "Team_what_is_this_team_about": "What is this team about", "Teams": "Teams", + "Teams_and_channels": "Teams and channels", "Teams_Errors_Already_exists": "The team `{{name}}` already exists.", "Teams_Errors_team_name": "You can't use \"{{name}}\" as a team name.", "Teams_Info": "Team info", diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index 6101dea65cbbc..96b90ff204a7e 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -39,6 +39,8 @@ export type UsersSetPreferencesParamsPOST = { sidebarViewMode?: string; sidebarDisplayAvatar?: boolean; sidebarGroupByType?: boolean; + sidebarGroupTeamsAndChannels?: boolean; + sidebarGroupUnlistedInConversations?: boolean; muteFocusedConversations?: boolean; dontAskAgainList?: Array<{ action: string; label: string }>; featuresPreview?: { name: string; value: boolean }[]; @@ -198,6 +200,14 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + sidebarGroupTeamsAndChannels: { + type: 'boolean', + nullable: true, + }, + sidebarGroupUnlistedInConversations: { + type: 'boolean', + nullable: true, + }, muteFocusedConversations: { type: 'boolean', nullable: true,