@@ -390,11 +404,13 @@ export const Conversations = ({
isTeam={isTeam}
changeTab={changeTab}
currentTab={currentTab}
+ conversations={conversations}
groupConversations={groupConversations}
directConversations={directConversations}
unreadConversations={unreadConversations}
favoriteConversations={favoriteConversations}
archivedConversations={archivedConversations}
+ draftConversations={draftConversations}
conversationRepository={conversationRepository}
onClickPreferences={onClickPreferences}
showNotificationsBadge={notifications.length > 0}
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/Helpers.test.tsx b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/Helpers.test.tsx
index 641540c6da5..44e570d7fe9 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/Helpers.test.tsx
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/Helpers.test.tsx
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2024 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,11 +17,136 @@
*
*/
+import {CONVERSATION_TYPE} from '@wireapp/api-client/lib/conversation';
+
import {Conversation} from 'Repositories/entity/Conversation';
-import {getTabConversations} from 'src/script/page/LeftSidebar/panels/Conversations/helpers';
+import {
+ conversationFilters,
+ conversationSearchFilter,
+ getConversationsWithHeadings,
+ getTabConversations,
+ scrollToConversation,
+} from 'src/script/page/LeftSidebar/panels/Conversations/helpers';
import {generateConversation} from 'test/helper/ConversationGenerator';
+import {generateUser} from 'test/helper/UserGenerator';
+
+import {SidebarTabs} from './useSidebarStore';
+
+describe('conversationFilters', () => {
+ it('detects mentions, replies, pings, and archived state', () => {
+ const mentionsConversation = generateConversation({name: 'Mentions'});
+ jest
+ .spyOn(mentionsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [{}], selfReplies: [], pings: []} as any);
+ jest.spyOn(mentionsConversation, 'is_archived').mockReturnValue(false as any);
+
+ const repliesConversation = generateConversation({name: 'Replies'});
+ jest
+ .spyOn(repliesConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [{}], pings: []} as any);
+ jest.spyOn(repliesConversation, 'is_archived').mockReturnValue(true as any);
+
+ const pingsConversation = generateConversation({name: 'Pings'});
+ jest
+ .spyOn(pingsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [], pings: [{}]} as any);
+ jest.spyOn(pingsConversation, 'is_archived').mockReturnValue(false as any);
+
+ expect(conversationFilters.hasMentions(mentionsConversation)).toBe(true);
+ expect(conversationFilters.hasReplies(repliesConversation)).toBe(true);
+ expect(conversationFilters.hasPings(pingsConversation)).toBe(true);
+ expect(conversationFilters.notArchived(repliesConversation)).toBe(false);
+ });
+});
+
+describe('conversationSearchFilter', () => {
+ it('matches conversations by normalized display name', () => {
+ const conversation = generateConversation({name: 'Wêb Têam'});
+ const matches = conversationSearchFilter('web team');
+
+ expect(matches(conversation)).toBe(true);
+ });
+});
+
+describe('scrollToConversation', () => {
+ const originalInnerHeight = window.innerHeight;
+ const originalInnerWidth = window.innerWidth;
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerHeight', {value: 600, configurable: true});
+ Object.defineProperty(window, 'innerWidth', {value: 800, configurable: true});
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'innerHeight', {value: originalInnerHeight, configurable: true});
+ Object.defineProperty(window, 'innerWidth', {value: originalInnerWidth, configurable: true});
+ document.body.innerHTML = '';
+ });
-import {SidebarTabs, ConversationFilter} from './useSidebarStore';
+ it('scrolls when the conversation is out of view', () => {
+ const conversationId = 'conv-1';
+ const element = document.createElement('div');
+ element.className = 'conversation-list-cell';
+ element.dataset.uieUid = conversationId;
+ element.getBoundingClientRect = jest.fn(() => ({
+ top: -10,
+ left: 0,
+ bottom: 10,
+ right: 10,
+ })) as any;
+ element.scrollIntoView = jest.fn();
+ document.body.appendChild(element);
+
+ scrollToConversation(conversationId);
+
+ expect(element.scrollIntoView).toHaveBeenCalledWith({behavior: 'instant', block: 'center', inline: 'nearest'});
+ });
+
+ it('does not scroll when the conversation is already visible', () => {
+ const conversationId = 'conv-2';
+ const element = document.createElement('div');
+ element.className = 'conversation-list-cell';
+ element.dataset.uieUid = conversationId;
+ element.getBoundingClientRect = jest.fn(() => ({
+ top: 10,
+ left: 10,
+ bottom: 100,
+ right: 100,
+ })) as any;
+ element.scrollIntoView = jest.fn();
+ document.body.appendChild(element);
+
+ scrollToConversation(conversationId);
+
+ expect(element.scrollIntoView).not.toHaveBeenCalled();
+ });
+});
+
+describe('getConversationsWithHeadings', () => {
+ it('adds people and group headings when filtering recent conversations', () => {
+ const directConversation = generateConversation({name: 'Direct'});
+ jest.spyOn(directConversation, 'isGroup').mockReturnValue(false as any);
+
+ const groupConversation = generateConversation({name: 'Group'});
+ jest.spyOn(groupConversation, 'isGroup').mockReturnValue(true as any);
+
+ const result = getConversationsWithHeadings([directConversation, groupConversation], 'group', SidebarTabs.RECENT);
+
+ expect(result[0]).toEqual({isHeader: true, heading: 'searchConversationNames'});
+ expect(result[1]).toBe(directConversation);
+ expect(result[2]).toEqual({isHeader: true, heading: 'searchGroupParticipants'});
+ expect(result[3]).toBe(groupConversation);
+ });
+
+ it('returns the list unchanged when not filtering recent conversations', () => {
+ const conversation = generateConversation({name: 'Direct'});
+ jest.spyOn(conversation, 'isGroup').mockReturnValue(false as any);
+
+ const result = getConversationsWithHeadings([conversation], '', SidebarTabs.RECENT);
+
+ expect(result).toEqual([conversation]);
+ });
+});
describe('getTabConversations', () => {
let conversations: Conversation[];
@@ -31,11 +156,31 @@ describe('getTabConversations', () => {
let archivedConversations: Conversation[];
beforeEach(() => {
- const conversation1 = generateConversation({name: 'Virgile'});
- const conversation2 = generateConversation({name: 'Tim'});
- const conversation3 = generateConversation({name: 'Bardia'});
- const conversation4 = generateConversation({name: 'Tom'});
- const conversation5 = generateConversation({name: 'Wêb Têam'});
+ const conversation1 = generateConversation({
+ name: 'Virgile',
+ type: CONVERSATION_TYPE.ONE_TO_ONE,
+ users: [generateUser(undefined, {name: 'Virgile'})],
+ });
+ const conversation2 = generateConversation({
+ name: 'Tim',
+ type: CONVERSATION_TYPE.ONE_TO_ONE,
+ users: [generateUser(undefined, {name: 'Tim'})],
+ });
+ const conversation3 = generateConversation({
+ name: 'Bardia',
+ type: CONVERSATION_TYPE.ONE_TO_ONE,
+ users: [generateUser(undefined, {name: 'Bardia'})],
+ });
+ const conversation4 = generateConversation({
+ name: 'Tom',
+ type: CONVERSATION_TYPE.ONE_TO_ONE,
+ users: [generateUser(undefined, {name: 'Tom'})],
+ });
+ const conversation5 = generateConversation({
+ name: 'Wêb Têam',
+ type: CONVERSATION_TYPE.REGULAR,
+ users: [generateUser(undefined, {name: 'Wêb Têam'})],
+ });
conversations = [conversation1, conversation2, conversation3, conversation4, conversation5];
groupConversations = [conversation5];
@@ -56,7 +201,6 @@ describe('getTabConversations', () => {
channelAndGroupConversations: groupConversations,
channelConversations: [],
isChannelsEnabled: false,
- conversationFilter: ConversationFilter.NONE,
draftConversations: [],
});
};
@@ -75,6 +219,28 @@ describe('getTabConversations', () => {
expect(searchInputPlaceholder).toBe('searchGroupConversations');
});
+ it('should use group conversations when channels are enabled', () => {
+ const extraConversation = generateConversation({name: 'Channel Group'});
+ const channelAndGroupConversations = [groupConversations[0], extraConversation];
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.GROUPS,
+ conversations,
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations,
+ conversationsFilter: '',
+ channelAndGroupConversations,
+ channelConversations: [],
+ isChannelsEnabled: true,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual(groupConversations);
+ expect(searchInputPlaceholder).toBe('searchGroupConversations');
+ });
+
it('should return direct conversations if the current tab is DIRECTS', () => {
const {conversations: filteredConversations, searchInputPlaceholder} = runTest(SidebarTabs.DIRECTS, '');
@@ -123,7 +289,6 @@ describe('getTabConversations', () => {
channelAndGroupConversations: newGroupConversations,
channelConversations: [],
isChannelsEnabled: false,
- conversationFilter: ConversationFilter.NONE,
draftConversations: [],
});
@@ -138,7 +303,6 @@ describe('getTabConversations', () => {
channelAndGroupConversations: groupConversations,
channelConversations: [],
isChannelsEnabled: false,
- conversationFilter: ConversationFilter.NONE,
draftConversations: [],
});
@@ -155,4 +319,163 @@ describe('getTabConversations', () => {
expect(filteredConversations).toEqual([]);
expect(searchInputPlaceholder).toBe('');
});
+
+ it('should return unread conversations when current tab is UNREAD', () => {
+ const unreadConversation = generateConversation({name: 'Unread'});
+ jest.spyOn(unreadConversation, 'hasUnread').mockReturnValue(true as any);
+
+ const readConversation = generateConversation({name: 'Read'});
+ jest.spyOn(readConversation, 'hasUnread').mockReturnValue(false as any);
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.UNREAD,
+ conversations: [unreadConversation, readConversation],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [],
+ isChannelsEnabled: false,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual([unreadConversation]);
+ expect(searchInputPlaceholder).toBe('searchUnreadConversations');
+ });
+
+ it('should return draft conversations when current tab is DRAFTS', () => {
+ const draftConversation = generateConversation({name: 'Draft'});
+ const nonDraftConversation = generateConversation({name: 'NoDraft'});
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.DRAFTS,
+ conversations: [draftConversation, nonDraftConversation],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [],
+ isChannelsEnabled: false,
+ draftConversations: [draftConversation],
+ });
+
+ expect(filteredConversations).toEqual([draftConversation]);
+ expect(searchInputPlaceholder).toBe('searchDraftsConversations');
+ });
+
+ it('should return channels that are not archived when current tab is CHANNELS', () => {
+ const activeChannel = generateConversation({name: 'Active Channel'});
+ jest.spyOn(activeChannel, 'is_archived').mockReturnValue(false as any);
+
+ const archivedChannel = generateConversation({name: 'Archived Channel'});
+ jest.spyOn(archivedChannel, 'is_archived').mockReturnValue(true as any);
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.CHANNELS,
+ conversations: [activeChannel, archivedChannel],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [archivedChannel],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [activeChannel, archivedChannel],
+ isChannelsEnabled: true,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual([activeChannel]);
+ expect(searchInputPlaceholder).toBe('searchChannelConversations');
+ });
+
+ it('should return conversations with mentions when current tab is MENTIONS', () => {
+ const mentionsConversation = generateConversation({name: 'Mentions'});
+ jest
+ .spyOn(mentionsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [{}], selfReplies: [], pings: []} as any);
+
+ const noMentionsConversation = generateConversation({name: 'NoMentions'});
+ jest
+ .spyOn(noMentionsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [], pings: []} as any);
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.MENTIONS,
+ conversations: [mentionsConversation, noMentionsConversation],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [],
+ isChannelsEnabled: false,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual([mentionsConversation]);
+ expect(searchInputPlaceholder).toBe('searchMentionsConversations');
+ });
+
+ it('should return conversations with replies when current tab is REPLIES', () => {
+ const repliesConversation = generateConversation({name: 'Replies'});
+ jest
+ .spyOn(repliesConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [{}], pings: []} as any);
+
+ const noRepliesConversation = generateConversation({name: 'NoReplies'});
+ jest
+ .spyOn(noRepliesConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [], pings: []} as any);
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.REPLIES,
+ conversations: [repliesConversation, noRepliesConversation],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [],
+ isChannelsEnabled: false,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual([repliesConversation]);
+ expect(searchInputPlaceholder).toBe('searchRepliesConversations');
+ });
+
+ it('should return conversations with pings when current tab is PINGS', () => {
+ const pingsConversation = generateConversation({name: 'Pings'});
+ jest
+ .spyOn(pingsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [], pings: [{}]} as any);
+
+ const noPingsConversation = generateConversation({name: 'NoPings'});
+ jest
+ .spyOn(noPingsConversation, 'unreadState')
+ .mockReturnValue({selfMentions: [], selfReplies: [], pings: []} as any);
+
+ const {conversations: filteredConversations, searchInputPlaceholder} = getTabConversations({
+ currentTab: SidebarTabs.PINGS,
+ conversations: [pingsConversation, noPingsConversation],
+ groupConversations,
+ directConversations,
+ favoriteConversations,
+ archivedConversations: [],
+ conversationsFilter: '',
+ channelAndGroupConversations: groupConversations,
+ channelConversations: [],
+ isChannelsEnabled: false,
+ draftConversations: [],
+ });
+
+ expect(filteredConversations).toEqual([pingsConversation]);
+ expect(searchInputPlaceholder).toBe('searchPingsConversations');
+ });
});
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.styles.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.styles.ts
new file mode 100644
index 00000000000..c60805223c3
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.styles.ts
@@ -0,0 +1,116 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {CSSObject} from '@emotion/react';
+
+export const filterButton = (isActive: boolean): CSSObject => ({
+ background: 'none',
+ border: 'none',
+ padding: '4px',
+ cursor: 'pointer',
+ color: isActive ? 'var(--accent-color)' : 'var(--foreground)',
+ transition: 'color 0.15s ease',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ '&:hover': {
+ color: 'var(--accent-color)',
+ },
+ '&:focus': {
+ outline: 'none',
+ },
+ '& svg': {
+ fill: 'currentColor',
+ },
+});
+
+export const dropdown: CSSObject = {
+ position: 'absolute',
+ top: '100%',
+ right: 0,
+ marginTop: '4px',
+ width: 'max-content',
+ padding: '8px 0',
+ backgroundColor: 'var(--dropdown-menu-bg)',
+ borderRadius: '12px',
+ boxShadow: '0 0 1px 0 rgba(0, 0, 0, 0.08), 0 8px 24px 0 rgba(0, 0, 0, 0.16)',
+ zIndex: 10,
+ overflow: 'hidden',
+};
+
+export const dropdownCheckboxItem: CSSObject = {
+ display: 'flex',
+ alignItems: 'center',
+ height: '30px',
+ padding: '0 24px',
+ cursor: 'pointer',
+ transition: 'background-color 0.15s ease',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ backgroundColor: 'var(--foreground-fade-16)',
+ },
+};
+
+export const dropdownHeader: CSSObject = {
+ padding: '4px 16px 2px',
+ fontSize: '11px',
+ fontWeight: 400,
+ color: 'var(--foreground-fade-56)',
+};
+
+export const checkboxLabel: CSSObject = {
+ fontSize: 'var(--font-size-small)',
+};
+
+export const dropdownDivider: CSSObject = {
+ width: '100%',
+ height: '1px',
+ borderTop: '1px solid var(--border-color)',
+ margin: '4px 0',
+};
+
+export const roundCheckbox: CSSObject = {
+ [`input[type="checkbox"] + label::before`]: {
+ borderRadius: '50% !important',
+ minWidth: '18px !important',
+ width: '18px !important',
+ height: '18px !important',
+ margin: '0 8px 0 0 !important',
+ },
+ [`input[type="checkbox"]:checked + label::before`]: {
+ borderWidth: '5px',
+ borderColor: 'var(--accent-color-500)',
+ background: 'var(--accent-color-500) !important',
+ },
+ [`input[type="checkbox"]:checked + label::after`]: {
+ display: 'none',
+ },
+ [`input[type="checkbox"] + label svg`]: {
+ width: '12px !important',
+ height: '12px !important',
+ position: 'absolute',
+ left: '3px',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ },
+};
+
+export const filterButtonWrapper: CSSObject = {
+ position: 'relative',
+};
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.test.tsx b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.test.tsx
new file mode 100644
index 00000000000..d1f22e7f542
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {fireEvent, render, waitFor} from '@testing-library/react';
+
+import en from 'I18n/en-US.json';
+import {Config} from 'src/script/Config';
+import {withTheme} from 'src/script/auth/util/test/TestUtil';
+import {setStrings} from 'Util/LocalizerUtil';
+
+import {TabsFilterButton} from './TabsFilterButton';
+import {SidebarTabs, useSidebarStore} from '../useSidebarStore';
+
+jest.mock('Util/useChannelsFeatureFlag', () => ({
+ useChannelsFeatureFlag: () => ({
+ isChannelsEnabled: false,
+ shouldShowChannelTab: false,
+ }),
+}));
+
+jest.mock('Repositories/team/TeamState', () => ({
+ TeamState: class {
+ isCellsEnabled = () => false;
+ },
+}));
+
+jest.mock('Util/ComponentUtil', () => ({
+ useKoSubscribableChildren: () => ({isCellsEnabled: false}),
+}));
+
+jest.mock('Components/Icon', () => ({
+ SettingsIcon: () =>
,
+}));
+
+describe('TabsFilterButton', () => {
+ beforeEach(() => {
+ setStrings({en});
+ Config._dangerouslySetConfigFeaturesForDebug({
+ ...Config.getConfig().FEATURE,
+ ENABLE_ADVANCED_FILTERS: true,
+ });
+ useSidebarStore.setState({
+ visibleTabs: [SidebarTabs.RECENT, SidebarTabs.FAVORITES, SidebarTabs.GROUPS],
+ });
+ });
+
+ it('opens the dropdown and toggles a tab visibility', async () => {
+ const {getByTitle, getByText} = render(withTheme(
));
+
+ fireEvent.click(getByTitle('Customize visible tabs'));
+
+ const favoritesLabel = getByText('Favorites');
+ fireEvent.click(favoritesLabel);
+
+ await waitFor(() => {
+ expect(useSidebarStore.getState().visibleTabs).not.toContain(SidebarTabs.FAVORITES);
+ });
+ });
+});
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.tsx b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.tsx
new file mode 100644
index 00000000000..25e820481d7
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/TabsFilterButton.tsx
@@ -0,0 +1,213 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {useState, useRef, useEffect, useId, useCallback} from 'react';
+
+import {container} from 'tsyringe';
+
+import {Checkbox, CheckboxLabel, TabIndex} from '@wireapp/react-ui-kit';
+
+import * as Icon from 'Components/Icon';
+import {TeamState} from 'Repositories/team/TeamState';
+import {Config} from 'src/script/Config';
+import {SidebarTabs, useSidebarStore} from 'src/script/page/LeftSidebar/panels/Conversations/useSidebarStore';
+import {useKoSubscribableChildren} from 'Util/ComponentUtil';
+import {handleEscDown, isKey, KEY, isEnterKey, isSpaceKey} from 'Util/KeyboardUtil';
+import {t} from 'Util/LocalizerUtil';
+import {useChannelsFeatureFlag} from 'Util/useChannelsFeatureFlag';
+
+import {
+ checkboxLabel,
+ dropdown,
+ dropdownCheckboxItem,
+ dropdownDivider,
+ dropdownHeader,
+ filterButton,
+ filterButtonWrapper,
+ roundCheckbox,
+} from './TabsFilterButton.styles';
+
+export const TabsFilterButton = () => {
+ const {visibleTabs, toggleTabVisibility} = useSidebarStore();
+ const [isOpen, setIsOpen] = useState(false);
+ const [focusedIndex, setFocusedIndex] = useState(0);
+ const dropdownRef = useRef
(null);
+ const buttonRef = useRef(null);
+ const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const menuId = useId();
+
+ const {shouldShowChannelTab} = useChannelsFeatureFlag();
+ const teamState = container.resolve(TeamState);
+ const {isCellsEnabled: isCellsEnabledForTeam} = useKoSubscribableChildren(teamState, ['isCellsEnabled']);
+
+ const showCells = Config.getConfig().FEATURE.ENABLE_CELLS && isCellsEnabledForTeam;
+
+ const availableTabs = [
+ {type: SidebarTabs.FAVORITES, label: t('conversationLabelFavorites')},
+ {type: SidebarTabs.GROUPS, label: t('conversationLabelGroups')},
+ {type: SidebarTabs.DIRECTS, label: t('conversationLabelDirects')},
+ {type: SidebarTabs.FOLDER, label: t('folderViewTooltip')},
+ {type: SidebarTabs.ARCHIVES, label: t('conversationFooterArchive')},
+ {type: SidebarTabs.UNREAD, label: t('conversationLabelUnread')},
+ {type: SidebarTabs.MENTIONS, label: t('conversationLabelMentions')},
+ {type: SidebarTabs.REPLIES, label: t('conversationLabelReplies')},
+ {type: SidebarTabs.DRAFTS, label: t('conversationLabelDrafts')},
+ {type: SidebarTabs.PINGS, label: t('conversationLabelPings')},
+ ];
+
+ if (shouldShowChannelTab) {
+ availableTabs.splice(2, 0, {type: SidebarTabs.CHANNELS, label: t('conversationLabelChannels')});
+ }
+
+ if (showCells) {
+ availableTabs.push({type: SidebarTabs.CELLS, label: t('cells.sidebar.title')});
+ }
+
+ const handleMenuKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // Handle Escape
+ handleEscDown(event, () => {
+ setIsOpen(false);
+ buttonRef.current?.focus();
+ });
+
+ // Handle arrow navigation
+ if (isKey(event, KEY.ARROW_DOWN)) {
+ event.preventDefault();
+ setFocusedIndex(prev => (prev + 1) % availableTabs.length);
+ } else if (isKey(event, KEY.ARROW_UP)) {
+ event.preventDefault();
+ setFocusedIndex(prev => (prev - 1 + availableTabs.length) % availableTabs.length);
+ }
+ },
+ [availableTabs],
+ );
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!event.target) {
+ return;
+ }
+
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ buttonRef.current &&
+ !buttonRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (isOpen) {
+ document.addEventListener('keydown', handleMenuKeyDown);
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleMenuKeyDown);
+ };
+ }, [isOpen, handleMenuKeyDown]);
+
+ // Focus the item when focusedIndex changes
+ useEffect(() => {
+ if (isOpen && itemRefs.current[focusedIndex]) {
+ itemRefs.current[focusedIndex]?.focus();
+ }
+ }, [isOpen, focusedIndex]);
+
+ // Reset focused index when dropdown opens
+ useEffect(() => {
+ if (isOpen) {
+ setFocusedIndex(0);
+ }
+ }, [isOpen]);
+
+ if (!Config.getConfig().FEATURE.ENABLE_ADVANCED_FILTERS) {
+ return null;
+ }
+
+ return (
+
+ setIsOpen(!isOpen)}
+ data-uie-name="tabs-filter-button"
+ title={t('tabsFilterTooltip')}
+ css={filterButton(isOpen)}
+ type="button"
+ aria-label={t('tabsFilterTooltip')}
+ aria-expanded={isOpen}
+ aria-haspopup="menu"
+ aria-controls={menuId}
+ >
+
+
+
+ {isOpen && (
+
+ )}
+
+ );
+};
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/ConversationFilterButton/index.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/index.ts
similarity index 87%
rename from apps/webapp/src/script/page/LeftSidebar/panels/Conversations/ConversationFilterButton/index.ts
rename to apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/index.ts
index bc4a01b302f..886a6314361 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/ConversationFilterButton/index.ts
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/TabsFilterButton/index.ts
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2025 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,4 +17,4 @@
*
*/
-export * from './ConversationFilterButton';
+export {TabsFilterButton} from './TabsFilterButton';
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/helpers.tsx b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/helpers.tsx
index f08d2447370..ae000e0e059 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/helpers.tsx
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/helpers.tsx
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2024 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -21,7 +21,15 @@ import {Conversation} from 'Repositories/entity/Conversation';
import {t} from 'Util/LocalizerUtil';
import {replaceAccents} from 'Util/StringUtil';
-import {ConversationFilter, SidebarTabs} from './useSidebarStore';
+import {SidebarTabs} from './useSidebarStore';
+
+export const conversationFilters = {
+ hasUnread: (conv: Conversation) => conv.hasUnread(),
+ hasMentions: (conv: Conversation) => conv.unreadState().selfMentions.length > 0,
+ hasReplies: (conv: Conversation) => conv.unreadState().selfReplies.length > 0,
+ hasPings: (conv: Conversation) => conv.unreadState().pings.length > 0,
+ notArchived: (conv: Conversation) => !conv.is_archived(),
+};
interface GetTabConversationsProps {
currentTab: SidebarTabs;
@@ -35,7 +43,6 @@ interface GetTabConversationsProps {
channelAndGroupConversations: Conversation[];
conversationsFilter: string;
isChannelsEnabled: boolean;
- conversationFilter: ConversationFilter;
draftConversations: Conversation[];
}
@@ -44,33 +51,6 @@ type GetTabConversations = {
searchInputPlaceholder: string;
};
-const applyAdvancedFilter = (
- conversations: Conversation[],
- filter: ConversationFilter,
- draftConversations: Conversation[],
-): Conversation[] => {
- if (filter === ConversationFilter.NONE) {
- return conversations;
- }
-
- return conversations.filter(conversation => {
- switch (filter) {
- case ConversationFilter.UNREAD:
- return conversation.hasUnread();
- case ConversationFilter.MENTIONS:
- return conversation.unreadState().selfMentions.length > 0;
- case ConversationFilter.REPLIES:
- return conversation.unreadState().selfReplies.length > 0;
- case ConversationFilter.DRAFTS:
- return draftConversations.some(draftConv => draftConv.id === conversation.id);
- case ConversationFilter.PINGS:
- return conversation.unreadState().pings.length > 0;
- default:
- return true;
- }
- });
-};
-
export function getTabConversations({
currentTab,
conversations,
@@ -82,7 +62,6 @@ export function getTabConversations({
channelConversations,
isChannelsEnabled,
channelAndGroupConversations,
- conversationFilter,
draftConversations,
}: GetTabConversationsProps): GetTabConversations {
const conversationSearchFilter = (conversation: Conversation) => {
@@ -97,7 +76,7 @@ export function getTabConversations({
if ([SidebarTabs.FOLDER, SidebarTabs.RECENT].includes(currentTab)) {
if (!conversationsFilter) {
return {
- conversations: applyAdvancedFilter(conversations, conversationFilter, draftConversations),
+ conversations: conversations,
searchInputPlaceholder: t('searchConversations'),
};
}
@@ -114,7 +93,7 @@ export function getTabConversations({
const combinedConversations = [...filteredConversations, ...filteredGroupConversations];
return {
- conversations: applyAdvancedFilter(combinedConversations, conversationFilter, draftConversations),
+ conversations: combinedConversations,
searchInputPlaceholder: t('searchConversations'),
};
}
@@ -124,7 +103,7 @@ export function getTabConversations({
const filteredConversations = conversations.filter(conversationArchivedFilter).filter(conversationSearchFilter);
return {
- conversations: applyAdvancedFilter(filteredConversations, conversationFilter, draftConversations),
+ conversations: filteredConversations,
searchInputPlaceholder: t('searchGroupConversations'),
};
}
@@ -135,7 +114,7 @@ export function getTabConversations({
.filter(conversationSearchFilter);
return {
- conversations: applyAdvancedFilter(filteredConversations, conversationFilter, draftConversations),
+ conversations: filteredConversations,
searchInputPlaceholder: t('searchChannelConversations'),
};
}
@@ -146,7 +125,7 @@ export function getTabConversations({
.filter(conversationSearchFilter);
return {
- conversations: applyAdvancedFilter(filteredConversations, conversationFilter, draftConversations),
+ conversations: filteredConversations,
searchInputPlaceholder: t('searchDirectConversations'),
};
}
@@ -155,7 +134,7 @@ export function getTabConversations({
const filteredConversations = favoriteConversations.filter(conversationSearchFilter);
return {
- conversations: applyAdvancedFilter(filteredConversations, conversationFilter, draftConversations),
+ conversations: filteredConversations,
searchInputPlaceholder: t('searchFavoriteConversations'),
};
}
@@ -164,11 +143,70 @@ export function getTabConversations({
const filteredConversations = archivedConversations.filter(conversationSearchFilter);
return {
- conversations: applyAdvancedFilter(filteredConversations, conversationFilter, draftConversations),
+ conversations: filteredConversations,
searchInputPlaceholder: t('searchArchivedConversations'),
};
}
+ if (currentTab === SidebarTabs.UNREAD) {
+ const filteredConversations = conversations
+ .filter(conversationArchivedFilter)
+ .filter(conversationFilters.hasUnread)
+ .filter(conversationSearchFilter);
+
+ return {
+ conversations: filteredConversations,
+ searchInputPlaceholder: t('searchUnreadConversations'),
+ };
+ }
+
+ if (currentTab === SidebarTabs.MENTIONS) {
+ const filteredConversations = conversations
+ .filter(conversationArchivedFilter)
+ .filter(conversationFilters.hasMentions)
+ .filter(conversationSearchFilter);
+
+ return {
+ conversations: filteredConversations,
+ searchInputPlaceholder: t('searchMentionsConversations'),
+ };
+ }
+
+ if (currentTab === SidebarTabs.REPLIES) {
+ const filteredConversations = conversations
+ .filter(conversationArchivedFilter)
+ .filter(conversationFilters.hasReplies)
+ .filter(conversationSearchFilter);
+
+ return {
+ conversations: filteredConversations,
+ searchInputPlaceholder: t('searchRepliesConversations'),
+ };
+ }
+
+ if (currentTab === SidebarTabs.DRAFTS) {
+ const filteredConversations = draftConversations
+ .filter(conversationArchivedFilter)
+ .filter(conversationSearchFilter);
+
+ return {
+ conversations: filteredConversations,
+ searchInputPlaceholder: t('searchDraftsConversations'),
+ };
+ }
+
+ if (currentTab === SidebarTabs.PINGS) {
+ const filteredConversations = conversations
+ .filter(conversationArchivedFilter)
+ .filter(conversationFilters.hasPings)
+ .filter(conversationSearchFilter);
+
+ return {
+ conversations: filteredConversations,
+ searchInputPlaceholder: t('searchPingsConversations'),
+ };
+ }
+
return {
conversations: [],
searchInputPlaceholder: '',
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.test.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.test.ts
new file mode 100644
index 00000000000..c495a66322c
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.test.ts
@@ -0,0 +1,79 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {act, renderHook, waitFor} from '@testing-library/react';
+import {amplify} from 'amplify';
+
+import {StorageKey} from 'Repositories/storage';
+import {generateConversation} from 'test/helper/ConversationGenerator';
+
+import {useDraftConversations} from './useDraftConversations';
+
+jest.mock('amplify');
+
+describe('useDraftConversations', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ Storage.prototype.getItem = jest.fn();
+ (amplify.subscribe as jest.Mock).mockImplementation(() => undefined);
+ (amplify.unsubscribe as jest.Mock).mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ jest.clearAllMocks();
+ });
+
+ it('returns conversations with drafts on initial check', async () => {
+ const draftConversation = generateConversation({name: 'Draft'});
+ const otherConversation = generateConversation({name: 'Other'});
+ const draftKey = `__amplify__${StorageKey.CONVERSATION.INPUT}|${draftConversation.id}`;
+ const draftData = JSON.stringify({data: {plainMessage: 'Hello'}});
+
+ (localStorage.getItem as jest.Mock).mockImplementation(key => (key === draftKey ? draftData : null));
+
+ const {result} = renderHook(() => useDraftConversations([draftConversation, otherConversation]));
+
+ await waitFor(() => expect(result.current).toEqual([draftConversation]));
+ });
+
+ it('updates when a draft change event is published', async () => {
+ const draftConversation = generateConversation({name: 'Draft'});
+ const draftKey = `__amplify__${StorageKey.CONVERSATION.INPUT}|${draftConversation.id}`;
+
+ (localStorage.getItem as jest.Mock).mockImplementation(() => null);
+
+ const {result} = renderHook(() => useDraftConversations([draftConversation]));
+
+ const subscribeCall = (amplify.subscribe as jest.Mock).mock.calls[0];
+ const handleDraftChange = subscribeCall[1];
+
+ (localStorage.getItem as jest.Mock).mockImplementation(key =>
+ key === draftKey ? JSON.stringify({data: {plainMessage: 'Later'}}) : null,
+ );
+
+ act(() => {
+ handleDraftChange();
+ jest.advanceTimersByTime(250);
+ });
+
+ await waitFor(() => expect(result.current).toEqual([draftConversation]));
+ });
+});
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.ts
index 05adb660f8b..00e32bdebd6 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.ts
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/hooks/useDraftConversations.ts
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2025 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,8 +19,10 @@
import {useEffect, useState, useRef, useCallback} from 'react';
+import {amplify} from 'amplify';
import {useDebouncedCallback} from 'use-debounce';
+import {DRAFT_STATE_CHANGED_EVENT} from 'Components/InputBar/common/draftState/draftState';
import {Conversation} from 'Repositories/entity/Conversation';
import {StorageKey} from 'Repositories/storage';
@@ -37,6 +39,11 @@ export const useDraftConversations = (conversations: Conversation[]): Conversati
}, [conversations]);
const checkForDrafts = useCallback(() => {
+ // Early return if no conversations to check
+ if (!conversationsRef.current.length) {
+ return;
+ }
+
const storageKeyPrefix = `__amplify__${StorageKey.CONVERSATION.INPUT}|`;
const conversationsWithDrafts: Conversation[] = [];
const currentCheck: {[key: string]: string} = {};
@@ -73,22 +80,33 @@ export const useDraftConversations = (conversations: Conversation[]): Conversati
// Initial check
checkForDrafts();
+ // Listen for draft changes in the current tab
+ const handleDraftChange = () => {
+ debouncedCheck();
+ };
+
// Listen for storage changes from other tabs
const handleStorageChange = (event: StorageEvent) => {
if (event.key?.includes(StorageKey.CONVERSATION.INPUT)) {
- checkForDrafts();
+ debouncedCheck();
}
};
- // Check periodically but less frequently
- // This matches the draft save debounce of 800ms
- const interval = setInterval(debouncedCheck, 1000);
+ // Listen for visibility changes to check when tab becomes active
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ checkForDrafts();
+ }
+ };
+ amplify.subscribe(DRAFT_STATE_CHANGED_EVENT, handleDraftChange);
window.addEventListener('storage', handleStorageChange);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
+ amplify.unsubscribe(DRAFT_STATE_CHANGED_EVENT, handleDraftChange);
window.removeEventListener('storage', handleStorageChange);
- clearInterval(interval);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
debouncedCheck.cancel();
};
}, [checkForDrafts, debouncedCheck]);
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.test.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.test.ts
new file mode 100644
index 00000000000..b626777c85b
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.test.ts
@@ -0,0 +1,88 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {SidebarStatus, SidebarTabs, isTabVisible, useSidebarStore} from './useSidebarStore';
+
+describe('useSidebarStore', () => {
+ beforeEach(() => {
+ Storage.prototype.getItem = jest.fn();
+ Storage.prototype.setItem = jest.fn();
+ Storage.prototype.removeItem = jest.fn();
+ useSidebarStore.setState({
+ currentTab: SidebarTabs.RECENT,
+ visibleTabs: [
+ SidebarTabs.RECENT,
+ SidebarTabs.FOLDER,
+ SidebarTabs.FAVORITES,
+ SidebarTabs.GROUPS,
+ SidebarTabs.CHANNELS,
+ SidebarTabs.DIRECTS,
+ SidebarTabs.UNREAD,
+ SidebarTabs.MENTIONS,
+ SidebarTabs.REPLIES,
+ SidebarTabs.DRAFTS,
+ SidebarTabs.PINGS,
+ SidebarTabs.ARCHIVES,
+ ],
+ });
+ });
+
+ it('keeps RECENT visible even when toggled', () => {
+ useSidebarStore.getState().toggleTabVisibility(SidebarTabs.RECENT);
+
+ expect(useSidebarStore.getState().visibleTabs).toContain(SidebarTabs.RECENT);
+ });
+
+ it('hides active tab and falls back to RECENT', () => {
+ useSidebarStore.setState({currentTab: SidebarTabs.GROUPS});
+
+ useSidebarStore.getState().toggleTabVisibility(SidebarTabs.GROUPS);
+
+ expect(useSidebarStore.getState().currentTab).toBe(SidebarTabs.RECENT);
+ expect(useSidebarStore.getState().visibleTabs).not.toContain(SidebarTabs.GROUPS);
+ });
+
+ it('sets current tab', () => {
+ useSidebarStore.getState().setCurrentTab(SidebarTabs.MENTIONS);
+
+ expect(useSidebarStore.getState().currentTab).toBe(SidebarTabs.MENTIONS);
+ });
+
+ it('sets sidebar status', () => {
+ useSidebarStore.getState().setStatus(SidebarStatus.CLOSED);
+
+ expect(useSidebarStore.getState().status).toBe(SidebarStatus.CLOSED);
+ });
+
+ it('sets visible tabs', () => {
+ const visibleTabs = [SidebarTabs.RECENT, SidebarTabs.FAVORITES];
+
+ useSidebarStore.getState().setVisibleTabs(visibleTabs);
+
+ expect(useSidebarStore.getState().visibleTabs).toEqual(visibleTabs);
+ });
+
+ it('checks tab visibility', () => {
+ const visibleTabs = [SidebarTabs.FAVORITES];
+
+ expect(isTabVisible(SidebarTabs.RECENT, visibleTabs)).toBe(true);
+ expect(isTabVisible(SidebarTabs.FAVORITES, visibleTabs)).toBe(true);
+ expect(isTabVisible(SidebarTabs.DRAFTS, visibleTabs)).toBe(false);
+ });
+});
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.ts
index 9c87fe4f3ae..ae7b4b25c51 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.ts
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/useSidebarStore.ts
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2024 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -27,20 +27,27 @@ export enum SidebarTabs {
GROUPS,
CHANNELS,
DIRECTS,
+ UNREAD,
+ MENTIONS,
+ REPLIES,
+ DRAFTS,
+ PINGS,
ARCHIVES,
CONNECT,
PREFERENCES,
CELLS,
}
-export enum ConversationFilter {
- NONE = 'NONE',
- UNREAD = 'UNREAD',
- MENTIONS = 'MENTIONS',
- REPLIES = 'REPLIES',
- DRAFTS = 'DRAFTS',
- PINGS = 'PINGS',
-}
+/**
+ * Tabs that are always visible and cannot be hidden by the user.
+ * RECENT tab serves as the default view and fallback when users (accidentally)
+ * hide their currently active tab.
+ */
+export const ALWAYS_VISIBLE_TABS: readonly SidebarTabs[] = [SidebarTabs.RECENT];
+
+export const isTabVisible = (tab: SidebarTabs, visibleTabs: SidebarTabs[]): boolean => {
+ return ALWAYS_VISIBLE_TABS.includes(tab) || visibleTabs.includes(tab);
+};
export const SidebarStatus = {
OPEN: 'OPEN',
@@ -54,8 +61,9 @@ export interface SidebarStore {
setStatus: (status: SidebarStatus) => void;
currentTab: SidebarTabs;
setCurrentTab: (tab: SidebarTabs) => void;
- conversationFilter: ConversationFilter;
- setConversationFilter: (filter: ConversationFilter) => void;
+ visibleTabs: SidebarTabs[];
+ setVisibleTabs: (tabs: SidebarTabs[]) => void;
+ toggleTabVisibility: (tab: SidebarTabs) => void;
}
const useSidebarStore = create()(
@@ -67,8 +75,44 @@ const useSidebarStore = create()(
},
status: SidebarStatus.OPEN,
setStatus: status => set({status: status}),
- conversationFilter: ConversationFilter.NONE,
- setConversationFilter: (filter: ConversationFilter) => set({conversationFilter: filter}),
+ visibleTabs: [
+ SidebarTabs.RECENT,
+ SidebarTabs.FOLDER,
+ SidebarTabs.FAVORITES,
+ SidebarTabs.GROUPS,
+ SidebarTabs.CHANNELS,
+ SidebarTabs.DIRECTS,
+ SidebarTabs.UNREAD,
+ SidebarTabs.MENTIONS,
+ SidebarTabs.REPLIES,
+ SidebarTabs.DRAFTS,
+ SidebarTabs.PINGS,
+ SidebarTabs.ARCHIVES,
+ ],
+ setVisibleTabs: (tabs: SidebarTabs[]) => set({visibleTabs: tabs}),
+ toggleTabVisibility: (tab: SidebarTabs) => {
+ if (ALWAYS_VISIBLE_TABS.includes(tab)) {
+ return;
+ }
+
+ set(state => {
+ const isCurrentlyVisible = state.visibleTabs.includes(tab);
+ const isActiveTab = state.currentTab === tab;
+
+ if (isCurrentlyVisible && isActiveTab) {
+ return {
+ currentTab: SidebarTabs.RECENT,
+ visibleTabs: state.visibleTabs.filter(visibleTab => visibleTab !== tab),
+ };
+ }
+
+ const newVisibleTabs = isCurrentlyVisible
+ ? state.visibleTabs.filter(visibleTab => visibleTab !== tab)
+ : [...state.visibleTabs, tab];
+
+ return {visibleTabs: newVisibleTabs};
+ });
+ },
}),
{
name: 'sidebar-store',
@@ -78,7 +122,7 @@ const useSidebarStore = create()(
currentTab: [SidebarTabs.PREFERENCES, SidebarTabs.CONNECT, SidebarTabs.CELLS].includes(state.currentTab)
? SidebarTabs.RECENT
: state.currentTab,
- conversationFilter: state.conversationFilter,
+ visibleTabs: state.visibleTabs,
}),
},
),
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.test.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.test.ts
new file mode 100644
index 00000000000..2db974a022b
--- /dev/null
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.test.ts
@@ -0,0 +1,65 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {StorageKey} from 'Repositories/storage';
+import {generateConversation} from 'test/helper/ConversationGenerator';
+
+import {conversationHasDraft} from './draftUtils';
+
+describe('conversationHasDraft', () => {
+ beforeEach(() => {
+ Storage.prototype.getItem = jest.fn();
+ });
+
+ it('returns false when no draft data exists', () => {
+ const conversation = generateConversation();
+ (localStorage.getItem as jest.Mock).mockReturnValue(null);
+
+ expect(conversationHasDraft(conversation)).toBe(false);
+ });
+
+ it('returns false when draft data has no content', () => {
+ const conversation = generateConversation();
+ const storageKey = `__amplify__${StorageKey.CONVERSATION.INPUT}|${conversation.id}`;
+ const draftData = JSON.stringify({data: {plainMessage: ' '}});
+
+ (localStorage.getItem as jest.Mock).mockImplementation(key => (key === storageKey ? draftData : null));
+
+ expect(conversationHasDraft(conversation)).toBe(false);
+ });
+
+ it('returns true when draft data has content', () => {
+ const conversation = generateConversation();
+ const storageKey = `__amplify__${StorageKey.CONVERSATION.INPUT}|${conversation.id}`;
+ const draftData = JSON.stringify({data: {plainMessage: 'Hello'}});
+
+ (localStorage.getItem as jest.Mock).mockImplementation(key => (key === storageKey ? draftData : null));
+
+ expect(conversationHasDraft(conversation)).toBe(true);
+ });
+
+ it('returns false when draft data is malformed', () => {
+ const conversation = generateConversation();
+ const storageKey = `__amplify__${StorageKey.CONVERSATION.INPUT}|${conversation.id}`;
+
+ (localStorage.getItem as jest.Mock).mockImplementation(key => (key === storageKey ? 'not-json' : null));
+
+ expect(conversationHasDraft(conversation)).toBe(false);
+ });
+});
diff --git a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.ts b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.ts
index 2036f705a13..400ac03952b 100644
--- a/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.ts
+++ b/apps/webapp/src/script/page/LeftSidebar/panels/Conversations/utils/draftUtils.ts
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2025 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -51,8 +51,17 @@ export const conversationHasDraft = (conversation: Conversation): boolean => {
const amplifyData: AmplifyWrapper | DraftData = JSON.parse(draftData);
// Amplify wraps the data in an object with 'data' and 'expires' properties
const draft = (amplifyData as AmplifyWrapper).data || (amplifyData as DraftData);
- // Check if draft has content (editorState or plainMessage)
- return Boolean(draft && (draft.editorState || draft.plainMessage));
+
+ if (!draft) {
+ return false;
+ }
+
+ // Check plainMessage for actual content (not just whitespace)
+ const plainMessage = draft.plainMessage || '';
+ const hasTextContent = plainMessage.trim().length > 0;
+ const hasEditorStateContent = Boolean(draft.editorState);
+
+ return hasTextContent || hasEditorStateContent;
} catch (error: unknown) {
// Only log error type, not the actual error to avoid exposing draft content
logger.warn(
diff --git a/apps/webapp/src/script/util/KeyboardUtil.ts b/apps/webapp/src/script/util/KeyboardUtil.ts
index 6aaef27f941..af555621b68 100644
--- a/apps/webapp/src/script/util/KeyboardUtil.ts
+++ b/apps/webapp/src/script/util/KeyboardUtil.ts
@@ -63,7 +63,8 @@ export const isTabKey = (keyboardEvent: KeyboardEvent | ReactKeyboardEvent): boo
export const isEnterKey = (keyboardEvent: KeyboardEvent | ReactKeyboardEvent): boolean =>
isKey(keyboardEvent, KEY.ENTER);
-export const isSpaceKey = (keyboardEvent: KeyboardEvent): boolean => isKey(keyboardEvent, KEY.SPACE);
+export const isSpaceKey = (keyboardEvent: KeyboardEvent | ReactKeyboardEvent): boolean =>
+ isKey(keyboardEvent, KEY.SPACE);
export const isEscapeKey = (keyboardEvent: KeyboardEvent | ReactKeyboardEvent): boolean =>
isKey(keyboardEvent, KEY.ESC);
diff --git a/apps/webapp/src/script/util/useChannelsFeatureFlag.ts b/apps/webapp/src/script/util/useChannelsFeatureFlag.ts
index f3cc7503f64..70ce0da3b80 100644
--- a/apps/webapp/src/script/util/useChannelsFeatureFlag.ts
+++ b/apps/webapp/src/script/util/useChannelsFeatureFlag.ts
@@ -1,6 +1,6 @@
/*
* Wire
- * Copyright (C) 2025 Wire Swiss GmbH
+ * Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -21,6 +21,7 @@ import {ACCESS_TYPE, FEATURE_KEY, FEATURE_STATUS, Role} from '@wireapp/api-clien
import {container} from 'tsyringe';
import {Config} from 'src/script/Config';
+import {ConversationState} from 'src/script/repositories/conversation/ConversationState';
import {TeamState} from 'src/script/repositories/team/TeamState';
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
@@ -74,16 +75,25 @@ export const useChannelsFeatureFlag = () => {
const canCreatePublicChannels = useCanCreatePublicChannels();
const channelFeature = useChannelFeature();
const core = container.resolve(Core);
+ const conversationState = container.resolve(ConversationState);
+ const {channelConversations} = useKoSubscribableChildren(conversationState, ['channelConversations']);
+
const isChannelsEnabled =
Config.getConfig().FEATURE.ENABLE_CHANNELS &&
core.backendFeatures.version >= Config.getConfig().MIN_ENTERPRISE_LOGIN_V2_AND_CHANNELS_SUPPORTED_API_VERSION;
const canCreateChannels = useCanCreateChannels();
+ const isChannelsFeatureEnabled = channelFeature?.status === FEATURE_STATUS.ENABLED;
+
+ // Determine if the channel tab should be shown based on the same logic used in ConversationTabs
+ const shouldShowChannelTab =
+ isChannelsEnabled && (channelConversations.some(channel => !channel.is_archived()) || isChannelsFeatureEnabled);
return {
canCreateChannels,
isChannelsEnabled,
- isChannelsFeatureEnabled: channelFeature?.status === FEATURE_STATUS.ENABLED,
+ isChannelsFeatureEnabled,
isChannelsHistorySharingEnabled: Config.getConfig().FEATURE.ENABLE_CHANNELS_HISTORY_SHARING,
isPublicChannelsEnabled: canCreatePublicChannels && Config.getConfig().FEATURE.ENABLE_PUBLIC_CHANNELS,
+ shouldShowChannelTab,
};
};
diff --git a/apps/webapp/src/types/i18n.d.ts b/apps/webapp/src/types/i18n.d.ts
index dd4f9087816..be658ef6f85 100644
--- a/apps/webapp/src/types/i18n.d.ts
+++ b/apps/webapp/src/types/i18n.d.ts
@@ -767,9 +767,14 @@ declare module 'I18n/en-US.json' {
'conversationJustNow': `Just now`;
'conversationLabelChannels': `Channels`;
'conversationLabelDirects': `1:1 Conversations`;
+ 'conversationLabelDrafts': `Drafts`;
'conversationLabelFavorites': `Favorites`;
'conversationLabelGroups': `Groups`;
+ 'conversationLabelMentions': `Mentions`;
'conversationLabelPeople': `People`;
+ 'conversationLabelPings': `Pings`;
+ 'conversationLabelReplies': `Replies`;
+ 'conversationLabelUnread': `Unread`;
'conversationLearnMoreChannels': `Learn more about channels`;
'conversationLikesCaptionPlural': `[bold]{firstUser}[/bold] and [bold]{secondUser}[/bold]`;
'conversationLikesCaptionPluralMoreThan2': `[bold]{userNames}[/bold] and [showmore]{number} more[/showmore]`;
diff --git a/apps/webapp/test/e2e_tests/specs/RegressionSpecs/tabsFiltersFlagOff.spec.ts b/apps/webapp/test/e2e_tests/specs/RegressionSpecs/tabsFiltersFlagOff.spec.ts
new file mode 100644
index 00000000000..451f7ba9b1b
--- /dev/null
+++ b/apps/webapp/test/e2e_tests/specs/RegressionSpecs/tabsFiltersFlagOff.spec.ts
@@ -0,0 +1,69 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {PageManager} from 'test/e2e_tests/pageManager';
+import {test, expect, withLogin} from 'test/e2e_tests/test.fixtures';
+
+test.describe('Conversation tabs feature flag', () => {
+ test(
+ 'Legacy tabs render when advanced filters are disabled',
+ {tag: ['@regression']},
+ async ({createUser, createPage}) => {
+ test.skip(
+ process.env.FEATURE_ENABLE_ADVANCED_FILTERS === 'true',
+ 'Advanced filters are enabled in this environment',
+ );
+
+ const user = await createUser();
+ const pageManager = PageManager.from(await createPage(withLogin(user)));
+ const {components} = pageManager.webapp;
+ const page = pageManager.page;
+
+ await components.conversationSidebar().isPageLoaded();
+
+ await expect(page.getByTestId('tabs-filter-button')).toHaveCount(0);
+
+ const defaultTabs = [
+ 'go-recent-view',
+ 'go-favorites-view',
+ 'go-unread-view',
+ 'go-mentions-view',
+ 'go-pings-view',
+ 'go-replies-view',
+ 'go-drafts-view',
+ 'go-groups-view',
+ 'go-directs-view',
+ 'go-folders-view',
+ 'go-archive',
+ ];
+
+ for (const tab of defaultTabs) {
+ await expect(page.getByTestId(tab)).toBeVisible();
+ }
+
+ if (process.env.FEATURE_ENABLE_CHANNELS === 'true') {
+ await expect(page.getByTestId('go-channels-view')).toBeVisible();
+ }
+
+ if (process.env.FEATURE_ENABLE_CELLS === 'true') {
+ await expect(page.getByTestId('go-cells')).toBeVisible();
+ }
+ },
+ );
+});