Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/sidebar-teams-channels-merge.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 103 additions & 2 deletions apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<VideoConfContext.Provider
value={
Expand All @@ -90,10 +96,19 @@ const getWrapperSettings = ({
.withUser(user)
.withSubscriptions([...fakeRooms, fakeRoom && fakeRoom].filter(Boolean) as unknown as SubscriptionWithRoom[])
.withUserPreference('sidebarGroupByType', sidebarGroupByType)
.withUserPreference('sidebarGroupTeamsAndChannels', sidebarGroupTeamsAndChannels)
.withUserPreference('sidebarGroupUnlistedInConversations', sidebarGroupUnlistedInConversations)
.withUserPreference('sidebarShowFavorites', sidebarShowFavorites)
.withUserPreference('sidebarShowUnread', sidebarShowUnread)
.withSetting('Discussion_enabled', isDiscussionEnabled);

if (sidebarSectionsOrder) {
root = root.withUserPreference('sidebarSectionsOrder', sidebarSectionsOrder);
}

return root;
};

it('should return roomList, groupsCount and groupsList', async () => {
const {
result: {
Expand Down Expand Up @@ -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: {
Expand Down
55 changes: 52 additions & 3 deletions apps/meteor/client/sidebar/hooks/useRoomList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubscriptionWithRoom>;
groupsCount: number[];
Expand All @@ -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<typeof order>('sidebarSectionsOrder') ?? order;
Expand Down Expand Up @@ -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')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unconditionally injecting Teams_and_channels into effectiveOrder whenever it is absent makes it impossible for users to intentionally hide this group (it always gets re-added) and prevents the unlisted-to-Conversations routing from applying to merged-group rooms. Gate the injection on mergeTeamsAndChannels being active and on a legacy-order signal (e.g., Teams or Channels still being present in the saved order).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/sidebar/hooks/useRoomList.ts, line 167:

<comment>Unconditionally injecting `Teams_and_channels` into `effectiveOrder` whenever it is absent makes it impossible for users to intentionally hide this group (it always gets re-added) and prevents the unlisted-to-Conversations routing from applying to merged-group rooms. Gate the injection on `mergeTeamsAndChannels` being active and on a legacy-order signal (e.g., `Teams` or `Channels` still being present in the saved order).</comment>

<file context>
@@ -134,17 +142,56 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
+			// 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');
</file context>
Suggested change
if (!effectiveOrder.includes('Teams_and_channels')) {
const shouldInjectMergedGroup =
mergeTeamsAndChannels &&
!effectiveOrder.includes('Teams_and_channels') &&
(effectiveOrder.includes('Teams') || effectiveOrder.includes('Channels'));
if (shouldInjectMergedGroup) {

const channelsIndex = effectiveOrder.indexOf('Channels');
effectiveOrder.splice(channelsIndex === -1 ? effectiveOrder.length : channelsIndex + 1, 0, 'Teams_and_channels');
}
Comment on lines +166 to +170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only inject Teams_and_channels for legacy orders, not for intentionally hidden ones.

This reinserts the merged section whenever the key is absent, which makes it impossible to hide that group via sidebarSectionsOrder and prevents the new unlisted-to-Conversations path from ever applying to Teams_and_channels. Gate the injection on a legacy signal like an existing Teams or Channels entry.

Proposed fix
 			const effectiveOrder: string[] = [...sidebarOrder];
-			if (!effectiveOrder.includes('Teams_and_channels')) {
+			const shouldInjectMergedGroup =
+				mergeTeamsAndChannels &&
+				!effectiveOrder.includes('Teams_and_channels') &&
+				(effectiveOrder.includes('Teams') || effectiveOrder.includes('Channels'));
+
+			if (shouldInjectMergedGroup) {
 				const channelsIndex = effectiveOrder.indexOf('Channels');
 				effectiveOrder.splice(channelsIndex === -1 ? effectiveOrder.length : channelsIndex + 1, 0, 'Teams_and_channels');
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/meteor/client/sidebar/hooks/useRoomList.ts` around lines 166 - 170, The
code currently always reinserts 'Teams_and_channels' into effectiveOrder when
it's missing, preventing intentional hiding; modify the condition in the
useRoomList logic so you only inject 'Teams_and_channels' if it's missing AND a
legacy signal exists (e.g., the order contains 'Teams' or 'Channels'); update
the if that checks effectiveOrder.includes('Teams_and_channels') to also require
(effectiveOrder.includes('Teams') || effectiveOrder.includes('Channels')) before
splice so hiding via sidebarSectionsOrder works and the new
unlisted-to-'Conversations' path can apply.


// 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<SubscriptionWithRoom>();
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);

Expand Down Expand Up @@ -208,6 +255,8 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
sidebarShowUnread,
favoritesEnabled,
sidebarGroupByType,
sidebarGroupTeamsAndChannels,
groupUnlistedInConversations,
isDiscussionEnabled,
sidebarOrder,
collapsedGroups,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type AccountPreferencesData = {
sidebarViewMode?: string;
sidebarDisplayAvatar?: boolean;
sidebarGroupByType?: boolean;
sidebarGroupTeamsAndChannels?: boolean;
sidebarGroupUnlistedInConversations?: boolean;
masterVolume?: number;
notificationsSoundVolume?: number;
voipRingerVolume?: number;
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/server/methods/saveUserPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -119,6 +121,8 @@ export const saveUserPreferences = async (settings: Partial<UserPreferences>, 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),
Expand Down
11 changes: 11 additions & 0 deletions apps/meteor/server/settings/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -755,6 +765,7 @@ export const createAccountSettings = () =>
'Teams',
'Discussions',
'Channels',
'Teams_and_channels',
'Direct_Messages',
'Conversations',
];
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/tests/end-to-end/api/miscellaneous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ describe('miscellaneous', () => {
'sidebarViewMode',
'sidebarDisplayAvatar',
'sidebarGroupByType',
'sidebarGroupTeamsAndChannels',
'sidebarGroupUnlistedInConversations',
'sidebarSectionsOrder',
'muteFocusedConversations',
'notifyCalendarEvents',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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,
Expand Down
Loading