diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx
index 13831a14f7c..f96f044519b 100644
--- a/apps/webapp/src/script/components/Conversation/Conversation.tsx
+++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx
@@ -627,12 +627,14 @@ export const Conversation = ({
userRepository={repositories.user}
cellsRepository={repositories.cells}
conversationRepository={conversationRepository}
+ isSharedDriveSearchAndFiltersEnabled={isSharedDriveSearchAndFiltersEnabled}
isSearchViewOpen={isSharedDriveSearchAndFiltersEnabled && isSharedDriveSearchViewOpen}
onOpenSearchView={() => {
if (isSharedDriveSearchAndFiltersEnabled) {
setIsSharedDriveSearchViewOpen(true);
}
}}
+ onCloseSearchView={() => setIsSharedDriveSearchViewOpen(false)}
/>
)}
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx
index a841b5c60ba..f94ecb4df7f 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx
@@ -41,6 +41,7 @@ interface CellsTableProps {
conversationQualifiedId: QualifiedId;
conversationName: string;
onRefresh: () => void;
+ onCloseSearchView?: () => void;
}
export const CellsTable = ({
@@ -49,6 +50,7 @@ export const CellsTable = ({
conversationQualifiedId,
conversationName,
onRefresh,
+ onCloseSearchView,
}: CellsTableProps) => {
const table = useReactTable({
data: nodes,
@@ -57,6 +59,7 @@ export const CellsTable = ({
conversationQualifiedId,
conversationName,
onRefresh,
+ onCloseSearchView,
}),
getCoreRowModel: getCoreRowModel(),
});
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx
index 61dec5e468f..a41b6e2d453 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx
@@ -38,15 +38,17 @@ export const getCellsTableColumns = ({
conversationQualifiedId,
conversationName,
onRefresh,
+ onCloseSearchView,
}: {
cellsRepository: CellsRepository;
conversationQualifiedId: QualifiedId;
conversationName: string;
onRefresh: () => void;
+ onCloseSearchView?: () => void;
}) => [
columnHelper.accessor('name', {
header: t('cells.tableRow.name'),
- cell: info => ,
+ cell: info => ,
}),
columnHelper.accessor('owner', {
header: t('cells.tableRow.owner'),
@@ -84,6 +86,7 @@ export const getCellsTableColumns = ({
conversationQualifiedId={conversationQualifiedId}
conversationName={conversationName}
onRefresh={onRefresh}
+ onCloseSearchView={onCloseSearchView}
/>
);
},
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx
index 6c00fc7bd88..c8e92605424 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx
@@ -17,8 +17,6 @@
*
*/
-import {QualifiedId} from '@wireapp/api-client/lib/user/';
-
import {FolderIcon, PlayIcon} from '@wireapp/react-ui-kit';
import {FileTypeIcon} from 'Components/Conversation/common/FileTypeIcon/FileTypeIcon';
@@ -39,10 +37,10 @@ import {useCellsFilePreviewModal} from '../../common/CellsFilePreviewModalContex
interface CellsTableNameColumnProps {
node: CellNode;
- conversationQualifiedId: QualifiedId;
+ onCloseSearchView?: () => void;
}
-export const CellsTableNameColumn = ({node, conversationQualifiedId}: CellsTableNameColumnProps) => {
+export const CellsTableNameColumn = ({node, onCloseSearchView}: CellsTableNameColumnProps) => {
return (
<>
{node.name}
@@ -50,7 +48,7 @@ export const CellsTableNameColumn = ({node, conversationQualifiedId}: CellsTable
{node.type === CellNodeType.FILE ? (
) : (
-
+
)}
>
@@ -91,14 +89,22 @@ const FileNameColumn = ({file}: {file: CellFile}) => {
);
};
-const FolderNameColumn = ({name, conversationQualifiedId}: {name: string; conversationQualifiedId: QualifiedId}) => {
+const FolderNameColumn = ({
+ name,
+ path,
+ onCloseSearchView,
+}: {
+ name: string;
+ path: string;
+ onCloseSearchView?: () => void;
+}) => {
return (
<>
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx
index 77607004f87..b2b9edbaf22 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx
@@ -58,6 +58,7 @@ interface CellsTableRowOptionsProps {
conversationQualifiedId: QualifiedId;
conversationName: string;
onRefresh: () => void;
+ onCloseSearchView?: () => void;
}
export const CellsTableRowOptions = ({
@@ -66,6 +67,7 @@ export const CellsTableRowOptions = ({
conversationQualifiedId,
conversationName,
onRefresh,
+ onCloseSearchView,
}: CellsTableRowOptionsProps) => {
return (
@@ -81,6 +83,7 @@ export const CellsTableRowOptions = ({
conversationQualifiedId={conversationQualifiedId}
conversationName={conversationName}
onRefresh={onRefresh}
+ onCloseSearchView={onCloseSearchView}
/>
);
@@ -92,6 +95,7 @@ const CellsTableRowOptionsContent = ({
conversationQualifiedId,
conversationName,
onRefresh,
+ onCloseSearchView,
}: CellsTableRowOptionsProps) => {
const {fireAndForgetInvoker} = useApplicationContext();
const {handleOpenFile} = useCellsFilePreviewModal();
@@ -175,7 +179,7 @@ const CellsTableRowOptionsContent = ({
node.type === CellNodeType.FOLDER
- ? openFolder({conversationQualifiedId, name: node.name})
+ ? openFolder({path: node.path, onBeforeNavigate: onCloseSearchView})
: handleOpenFile(node)
}
>
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx
index c514d182431..34a6bd3d769 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx
@@ -58,8 +58,10 @@ interface ConversationCellsProps {
userRepository: UserRepository;
activeConversation: Conversation;
conversationRepository: ConversationRepository;
+ isSharedDriveSearchAndFiltersEnabled: boolean;
isSearchViewOpen: boolean;
onOpenSearchView: () => void;
+ onCloseSearchView: () => void;
}
export const ConversationCells = memo(
@@ -68,8 +70,10 @@ export const ConversationCells = memo(
userRepository,
activeConversation,
conversationRepository,
+ isSharedDriveSearchAndFiltersEnabled,
isSearchViewOpen,
onOpenSearchView,
+ onCloseSearchView,
}: ConversationCellsProps) => {
const {fireAndForgetInvoker} = useApplicationContext();
const {cellsState: initialCellState, name} = useKoSubscribableChildren(activeConversation, ['cellsState', 'name']);
@@ -92,7 +96,9 @@ export const ConversationCells = memo(
const {refresh, setOffset} = useGetAllCellsNodes({
cellsRepository,
conversationQualifiedId,
- enabled: isCellsStateReady,
+ //Without this, the browse hook's hashchange handler would compete with
+ // (and flap against) search results.
+ enabled: isCellsStateReady && !isSearchViewOpen,
fireAndForgetInvoker,
userRepository,
});
@@ -112,6 +118,7 @@ export const ConversationCells = memo(
cellsRepository,
conversationQualifiedId,
enabled: isCellsStateReady && isSearchViewOpen,
+ allowSearchWhenDisabled: !isSharedDriveSearchAndFiltersEnabled,
fireAndForgetInvoker,
userRepository,
filters: filterState,
@@ -164,6 +171,8 @@ export const ConversationCells = memo(
});
}, [loadMoreOffset, loadMoreSearchResults]);
+ const handleSearchViewClosure = isSearchViewOpen ? onCloseSearchView : undefined;
+
useOnPresignedUrlExpired({conversationId, refreshCallback: handleRefresh});
const isLoading = nodesStatus === 'loading';
@@ -208,6 +217,9 @@ export const ConversationCells = memo(
conversationQualifiedId={conversationQualifiedId}
conversationName={name}
onRefresh={handleRefresh}
+ // opening a folder must close search view and open the browse view
+ // with that folder (and breadcrumbs)
+ onCloseSearchView={handleSearchViewClosure}
/>
)}
{isCellsStatePending && !isRefreshing && (
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts
new file mode 100644
index 00000000000..0fda3c90aa0
--- /dev/null
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts
@@ -0,0 +1,63 @@
+/*
+ * 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 {openFolder} from './openFolder';
+
+const CONV_ID = 'abc-123';
+const DOMAIN = 'staging.zinfra.io';
+
+function currentHash(): string {
+ return window.location.hash.replace('#', '');
+}
+
+describe('openFolder', () => {
+ beforeEach(() => {
+ window.location.hash = '';
+ });
+
+ describe('absolute path (id@domain)', () => {
+ it('navigates to the folder when path has one level', () => {
+ openFolder({path: `${CONV_ID}@${DOMAIN}/Test`});
+
+ expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/Test`);
+ });
+
+ it('navigates to the correct nested folder', () => {
+ openFolder({path: `${CONV_ID}@${DOMAIN}/Test/2025/deep`});
+
+ expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/Test/2025/deep`);
+ });
+
+ it('encodes path segments with special characters', () => {
+ openFolder({path: `${CONV_ID}@${DOMAIN}/My Folder/Sub Folder`});
+
+ expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/My%20Folder/Sub%20Folder`);
+ });
+
+ it('calls onBeforeNavigate before changing the URL', () => {
+ const hashBeforeNavigate: string[] = [];
+ const onBeforeNavigate = () => hashBeforeNavigate.push(currentHash());
+
+ openFolder({path: `${CONV_ID}@${DOMAIN}/Test`, onBeforeNavigate});
+
+ expect(hashBeforeNavigate).toEqual(['']);
+ expect(currentHash()).toContain('/files/Test');
+ });
+ });
+});
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts
index 82ab04ae4f3..b207229646f 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts
@@ -20,29 +20,29 @@
import {MouseEvent as ReactMouseEvent} from 'react';
import {QualifiedId} from '@wireapp/api-client/lib/user/';
+import {parseQualifiedId} from '@wireapp/core/lib/util/qualifiedIdUtil';
import {generateConversationUrl} from 'src/script/router/routeGenerator';
import {createNavigate} from 'src/script/router/routerBindings';
-import {getCellsFilesPath} from '../getCellsFilesPath/getCellsFilesPath';
-
interface OpenFolderParams {
- conversationQualifiedId: QualifiedId;
- name: string;
+ path: string;
event?: ReactMouseEvent;
+ // Called before navigating so the search view can close before the browse view re-enables.
+ onBeforeNavigate?: () => void;
+ conversationQualifiedId?: QualifiedId;
}
-export const openFolder = ({conversationQualifiedId, name, event}: OpenFolderParams) => {
- const currentPath = getCellsFilesPath();
- const pathSegments = currentPath ? currentPath.split('/') : [];
- const encodedSegments = [...pathSegments, name].map(segment => encodeURIComponent(segment));
- const newPath = encodedSegments.join('/');
-
- createNavigate(
- generateConversationUrl({
- id: conversationQualifiedId.id,
- domain: conversationQualifiedId.domain,
- filePath: `files/${newPath}`,
- }),
- )(event);
+export const openFolder = ({path, event, onBeforeNavigate, conversationQualifiedId}: OpenFolderParams) => {
+ const stripped = path.startsWith('/') ? path.slice(1) : path;
+ const [firstSegment, ...rest] = stripped.split('/');
+
+ const isAbsolute = firstSegment.includes('@');
+ const {id, domain} = isAbsolute ? parseQualifiedId(firstSegment) : (conversationQualifiedId ?? {id: '', domain: ''});
+ const filePathParts = isAbsolute ? rest : stripped.split('/').filter(Boolean);
+
+ const filePath = `files/${filePathParts.map(encodeURIComponent).join('/')}`;
+
+ onBeforeNavigate?.();
+ createNavigate(generateConversationUrl({id, domain, filePath}))(event);
};
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts
new file mode 100644
index 00000000000..da8cfeabc6a
--- /dev/null
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts
@@ -0,0 +1,291 @@
+/*
+ * 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 {RestNodeCollection} from 'cells-sdk-ts';
+
+import {CellsRepository} from 'Repositories/cells/cellsRepository';
+import {UserRepository} from 'Repositories/user/userRepository';
+import {createExecutingFireAndForgetInvokerForTest} from 'src/script/page/testSupport/rootContextTestSupport';
+import {CellNode, CellNodeType} from 'src/script/types/cellNode';
+
+import {useConversationSearchFiles} from './useConversationSearchFiles';
+
+import type {ConversationDriveFiltersState} from '../common/driveFilters/driveFilters';
+import {useCellsStore} from '../common/useCellsStore/useCellsStore';
+
+const CONV_ID = 'conv-abc';
+const DOMAIN = 'staging.zinfra.io';
+const QUALIFIED_ID = {id: CONV_ID, domain: DOMAIN};
+
+const emptyFilters: ConversationDriveFiltersState = {
+ selectedTagIds: [],
+ selectedFileTypeIds: [],
+ selectedCreatorIds: [],
+ isSharedViaLink: false,
+};
+
+const staleFolderNode: CellNode = {
+ id: 'folder-id',
+ name: 'Arjita',
+ path: `${CONV_ID}@${DOMAIN}/Arjita`,
+ type: CellNodeType.FOLDER,
+ extension: '',
+ sizeMb: '-',
+ uploadedAtTimestamp: 0,
+ owner: '',
+ conversationName: '',
+ tags: [],
+ presignedUrlExpiresAt: null,
+ user: null,
+};
+
+type FakeCellsRepository = jest.Mocked>;
+type FakeUserRepository = jest.Mocked>;
+
+function createFakeCellsRepository(searchResult: Partial = {}): FakeCellsRepository {
+ return {searchNodes: jest.fn().mockResolvedValue({Nodes: [], ...searchResult})};
+}
+
+function createFakeUserRepository(): FakeUserRepository {
+ return {getUsersById: jest.fn().mockResolvedValue([])};
+}
+
+function createDeferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(resolvePromise => {
+ resolve = resolvePromise;
+ });
+ return {promise, resolve};
+}
+
+function renderSearchHook({
+ cellsRepository = createFakeCellsRepository(),
+ userRepository = createFakeUserRepository(),
+ enabled = true,
+ allowSearchWhenDisabled = false,
+ onClear = jest.fn(),
+ fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(),
+ filters = emptyFilters,
+}: {
+ cellsRepository?: FakeCellsRepository;
+ userRepository?: FakeUserRepository;
+ enabled?: boolean;
+ allowSearchWhenDisabled?: boolean;
+ onClear?: () => void;
+ fireAndForgetInvoker?: ReturnType;
+ filters?: ConversationDriveFiltersState;
+} = {}) {
+ return {
+ fireAndForgetInvoker,
+ onClear,
+ ...renderHook(() =>
+ useConversationSearchFiles({
+ cellsRepository: cellsRepository as unknown as CellsRepository,
+ userRepository: userRepository as unknown as UserRepository,
+ conversationQualifiedId: QUALIFIED_ID,
+ enabled,
+ allowSearchWhenDisabled,
+ fireAndForgetInvoker,
+ filters,
+ onClear,
+ }),
+ ),
+ };
+}
+
+describe('useConversationSearchFiles', () => {
+ beforeEach(() => {
+ useCellsStore.getState().clearAll({conversationId: CONV_ID});
+ window.location.hash = `/conversation/${CONV_ID}/${DOMAIN}/files`;
+ });
+
+ it('fires an initial fetch when enabled', async () => {
+ const {fireAndForgetInvoker} = renderSearchHook();
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(useCellsStore.getState().status).toBe('success');
+ });
+
+ it('does not fetch when disabled', async () => {
+ const cellsRepository = createFakeCellsRepository();
+ const {fireAndForgetInvoker} = renderSearchHook({cellsRepository, enabled: false});
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(cellsRepository.searchNodes).not.toHaveBeenCalled();
+ });
+
+ it('supports legacy inline searches while the dedicated search view is disabled', async () => {
+ const cellsRepository = createFakeCellsRepository({
+ Nodes: [{Path: `${CONV_ID}@${DOMAIN}/doc.pdf`, Type: 'LEAF', Uuid: 'doc.pdf'}],
+ });
+ const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest();
+ const {result} = renderSearchHook({
+ cellsRepository,
+ enabled: false,
+ allowSearchWhenDisabled: true,
+ fireAndForgetInvoker,
+ });
+
+ act(() => result.current.handleSearch('doc'));
+
+ await waitFor(() => expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toHaveLength(1));
+ expect(useCellsStore.getState().status).toBe('success');
+ });
+
+ it('uses the current folder as the search root when opening search from inside a folder', async () => {
+ window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/MyFolder`;
+ const cellsRepository = createFakeCellsRepository();
+ const {fireAndForgetInvoker} = renderSearchHook({cellsRepository});
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(cellsRepository.searchNodes).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: `${CONV_ID}@${DOMAIN}/MyFolder`,
+ recursive: false,
+ deleted: false,
+ sortBy: undefined,
+ sortDirection: undefined,
+ }),
+ );
+ });
+
+ it('loads recycle-bin contents when opening search from the recycle bin', async () => {
+ window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/recycle_bin`;
+ const cellsRepository = createFakeCellsRepository();
+ const {fireAndForgetInvoker} = renderSearchHook({cellsRepository});
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(cellsRepository.searchNodes).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: `${CONV_ID}@${DOMAIN}`,
+ recursive: false,
+ deleted: true,
+ }),
+ );
+ });
+
+ it('clears the previous browse rows as soon as search opens', async () => {
+ const search = createDeferred();
+ const cellsRepository = {searchNodes: jest.fn().mockReturnValue(search.promise)} as FakeCellsRepository;
+ const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest();
+
+ useCellsStore.getState().setNodes({
+ conversationId: CONV_ID,
+ nodes: [staleFolderNode],
+ });
+ useCellsStore.getState().setStatus('success');
+
+ renderSearchHook({cellsRepository, fireAndForgetInvoker});
+
+ expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toEqual([]);
+ expect(useCellsStore.getState().status).toBe('loading');
+
+ act(() => {
+ search.resolve({Nodes: []});
+ });
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+ });
+
+ it('does not write to the store when disabled mid-flight', async () => {
+ const search = createDeferred();
+ const cellsRepository = {searchNodes: jest.fn().mockReturnValue(search.promise)} as FakeCellsRepository;
+ const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest();
+
+ const {rerender} = renderHook(
+ ({enabled}: {enabled: boolean}) =>
+ useConversationSearchFiles({
+ cellsRepository: cellsRepository as unknown as CellsRepository,
+ userRepository: createFakeUserRepository() as unknown as UserRepository,
+ conversationQualifiedId: QUALIFIED_ID,
+ enabled,
+ fireAndForgetInvoker,
+ filters: emptyFilters,
+ onClear: jest.fn(),
+ }),
+ {initialProps: {enabled: true}},
+ );
+
+ act(() => rerender({enabled: false}));
+
+ act(() => {
+ search.resolve({Nodes: [{Path: `${CONV_ID}@${DOMAIN}/stale-file.txt`, Type: 'LEAF', Uuid: 'uuid-1'}]});
+ });
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toHaveLength(0);
+ });
+
+ it('searches recursively within the current folder when the user types a query', async () => {
+ window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/Arjita`;
+ const cellsRepository = createFakeCellsRepository();
+ const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest();
+ const {result} = renderSearchHook({cellsRepository, fireAndForgetInvoker});
+
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ act(() => result.current.handleSearch('doc'));
+
+ await waitFor(() =>
+ expect(cellsRepository.searchNodes).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: 'doc',
+ recursive: true,
+ path: `${CONV_ID}@${DOMAIN}/Arjita`,
+ }),
+ ),
+ );
+ });
+
+ it('searches recursively when an active filter is applied without a text query', async () => {
+ const cellsRepository = createFakeCellsRepository();
+ const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest();
+
+ renderSearchHook({
+ cellsRepository,
+ fireAndForgetInvoker,
+ filters: {
+ ...emptyFilters,
+ selectedFileTypeIds: ['pictures'],
+ },
+ });
+
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(cellsRepository.searchNodes).toHaveBeenCalledWith(
+ expect.objectContaining({
+ recursive: true,
+ }),
+ );
+ });
+
+ it('calls onClear when the search input is emptied', async () => {
+ const onClear = jest.fn();
+ const {result, fireAndForgetInvoker} = renderSearchHook({onClear});
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ act(() => result.current.handleSearch('test'));
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ act(() => result.current.handleSearch(''));
+ await act(() => fireAndForgetInvoker.waitUntilAllSettled());
+
+ expect(onClear).toHaveBeenCalled();
+ });
+});
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts
index 9558e273f49..3773c3d2ca3 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts
@@ -17,7 +17,7 @@
*
*/
-import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import is from '@sindresorhus/is';
import {QualifiedId} from '@wireapp/api-client/lib/user/';
@@ -46,19 +46,22 @@ interface UseConversationSearchFilesProps {
userRepository: UserRepository;
conversationQualifiedId: QualifiedId;
enabled: boolean;
+ allowSearchWhenDisabled?: boolean;
fireAndForgetInvoker: FireAndForgetInvoker;
filters: ConversationDriveFiltersState;
onClear?: () => void;
}
const DEBOUNCE_TIME = 300;
-const FETCH_ALL_QUERY = '*';
+
+const normalizeSearchQuery = (query: string): string => (is.nonEmptyStringAndNotWhitespace(query) ? query.trim() : '');
export const useConversationSearchFiles = ({
cellsRepository,
userRepository,
conversationQualifiedId,
enabled,
+ allowSearchWhenDisabled = false,
fireAndForgetInvoker,
filters,
onClear,
@@ -67,16 +70,20 @@ export const useConversationSearchFiles = ({
const [searchValue, setSearchValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
- const isInitialLoad = useRef(true);
const shouldPerformSearch = useRef(false);
const hasFiredInitialFetchRef = useRef(false);
+ const wasEnabledRef = useRef(false);
+ // Prevents stale in-flight responses from overwriting the store after the search view closes.
+ const enabledRef = useRef(enabled);
+ enabledRef.current = enabled;
+ const allowSearchWhenDisabledRef = useRef(allowSearchWhenDisabled);
+ allowSearchWhenDisabledRef.current = allowSearchWhenDisabled;
const searchParams = useMemo(() => toConversationDriveSearchParams(filters), [filters]);
const hasActiveParams = hasActiveSearchParams(searchParams);
const hadActiveSearchParamsRef = useRef(hasActiveParams);
- const {id} = conversationQualifiedId;
- const conversationPath = getCellsApiPath({conversationQualifiedId});
+ const {id, domain} = conversationQualifiedId;
const searchNodes = useCallback(
async ({
@@ -94,21 +101,38 @@ export const useConversationSearchFiles = ({
setError(null);
setStatus(append ? 'fetchingMore' : 'loading');
- const shouldSort = query.length === 0 || query === FETCH_ALL_QUERY;
const searchParams = toConversationDriveSearchParams(filtersParam);
+ // Recurse only when actually searching so the empty search view
+ // matches the browse view: folders first, then files.
+ const hasQuery = query.length > 0;
+ const hasFilters = hasActiveSearchParams(searchParams);
+ const isRecycleBin = getCellsFilesPath() === RECYCLE_BIN_PATH;
+ const isSearchingOrFiltering = hasQuery || hasFilters;
+
+ // recency sort for searches inside the recycle bin only
+ const forceRecencySort = isRecycleBin && hasQuery;
+
+ // search scopes to the folder the user is browsing
+ const searchRootPath = getCellsApiPath({conversationQualifiedId: {id, domain}});
+
const result = await cellsRepository.searchNodes({
query,
+ recursive: isSearchingOrFiltering,
limit: append ? LOAD_MORE_INCREMENT : LOAD_MORE_INITIAL_SIZE,
offset,
- path: conversationPath,
- sortBy: shouldSort ? 'mtime' : undefined,
- sortDirection: shouldSort ? 'desc' : undefined,
- type: 'file',
- deleted: getCellsFilesPath() === RECYCLE_BIN_PATH,
+ path: searchRootPath,
+ sortBy: forceRecencySort ? 'mtime' : undefined,
+ sortDirection: forceRecencySort ? 'desc' : undefined,
+ deleted: isRecycleBin,
...searchParams,
});
+ // Search may have closed while the lookup request was in flight.
+ if (!enabledRef.current && !allowSearchWhenDisabledRef.current) {
+ return;
+ }
+
if (result.Nodes === undefined || result.Nodes.length === 0) {
if (!append) {
setNodes({conversationId: id, nodes: []});
@@ -120,13 +144,14 @@ export const useConversationSearchFiles = ({
const users = await getUsersFromNodes({nodes: result.Nodes, userRepository});
+ // Search may also close while resolving node owners.
+ if (!enabledRef.current && !allowSearchWhenDisabledRef.current) {
+ return;
+ }
+
// filter out draft nodes from results
const filteredNodes = result.Nodes.filter(node => node.IsDraft !== true);
-
- const transformedNodes = transformDataToCellsNodes({
- nodes: filteredNodes,
- users,
- });
+ const transformedNodes = transformDataToCellsNodes({nodes: filteredNodes, users});
if (append) {
appendNodes({conversationId: id, nodes: transformedNodes});
@@ -136,11 +161,6 @@ export const useConversationSearchFiles = ({
const pagination = result.Pagination !== undefined ? transformToCellPagination(result.Pagination) : null;
setPagination({conversationId: id, pagination});
-
- if (isInitialLoad.current) {
- isInitialLoad.current = false;
- }
-
setStatus('success');
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error('Failed to load files', {cause: error});
@@ -159,9 +179,26 @@ export const useConversationSearchFiles = ({
},
// cellsRepository and userRepository are not dependencies because they're singletons
// eslint-disable-next-line react-hooks/exhaustive-deps
- [appendNodes, setNodes, setPagination, setStatus, setError, id, conversationPath],
+ [appendNodes, setNodes, setPagination, setStatus, setError, id, domain],
);
+ useLayoutEffect(() => {
+ if (!enabled) {
+ wasEnabledRef.current = false;
+ return;
+ }
+
+ if (wasEnabledRef.current) {
+ return;
+ }
+
+ wasEnabledRef.current = true;
+
+ // Clear stale browse rows before paint when the search view takes ownership of the shared store.
+ clearAll({conversationId: id});
+ setStatus('loading');
+ }, [clearAll, enabled, id, setStatus]);
+
const searchNodesDebounced = useDebouncedCallback(async (value: string) => {
shouldPerformSearch.current = false;
setSearchQuery(value);
@@ -202,7 +239,7 @@ export const useConversationSearchFiles = ({
if (preserveFilters && hasActiveParams) {
fireAndForgetInvoker.fireAndForget(async (): Promise => {
- await searchNodes({query: FETCH_ALL_QUERY, filters});
+ await searchNodes({query: '', filters});
});
return;
}
@@ -213,7 +250,7 @@ export const useConversationSearchFiles = ({
const handleReload = useCallback(async (): Promise => {
setStatus('loading');
clearAll({conversationId: id});
- await searchNodes({query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, filters});
+ await searchNodes({query: normalizeSearchQuery(searchQuery), filters});
}, [clearAll, filters, id, searchNodes, searchQuery, setStatus]);
useEffect(() => {
@@ -229,7 +266,7 @@ export const useConversationSearchFiles = ({
}
fireAndForgetInvoker.fireAndForget(async (): Promise => {
- await searchNodes({query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, filters});
+ await searchNodes({query: normalizeSearchQuery(searchQuery), filters});
});
}, [searchNodes, searchQuery, enabled, filters, hasActiveParams, fireAndForgetInvoker]);
@@ -246,10 +283,7 @@ export const useConversationSearchFiles = ({
}
hasFiredInitialFetchRef.current = true;
fireAndForgetInvoker.fireAndForget(async (): Promise => {
- await searchNodes({
- query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY,
- filters,
- });
+ await searchNodes({query: normalizeSearchQuery(searchQuery), filters});
});
}, [enabled, searchNodes, searchQuery, filters, fireAndForgetInvoker]);
@@ -264,12 +298,7 @@ export const useConversationSearchFiles = ({
const loadMore = useCallback(
async (offset: number): Promise => {
- await searchNodes({
- query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY,
- filters,
- offset,
- append: true,
- });
+ await searchNodes({query: normalizeSearchQuery(searchQuery), filters, offset, append: true});
},
[filters, searchNodes, searchQuery],
);
diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts
index d6a8fb45c3a..f84e6f37707 100644
--- a/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts
+++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts
@@ -17,7 +17,7 @@
*
*/
-import {useEffect, useCallback, useMemo, useState} from 'react';
+import {useEffect, useCallback, useState} from 'react';
import {QualifiedId} from '@wireapp/api-client/lib/user/';
@@ -53,15 +53,16 @@ export const useGetAllCellsNodes = ({
const [offset, setOffset] = useState(0);
const {domain, id} = conversationQualifiedId;
- const conversationPath = useMemo(() => getCellsApiPath({conversationQualifiedId: {domain, id}}), [domain, id]);
const fetchNodes = useCallback(async () => {
try {
setError(null);
setStatus('loading');
+ // Resolve the path fresh on every fetch (not memoized) so folder navigation works.
+ // Memoizing on [domain, id] froze it to the conversation root and broke navigation.
const result = await cellsRepository.getAllNodes({
- path: conversationPath,
+ path: getCellsApiPath({conversationQualifiedId: {domain, id}}),
limit: pageSize,
offset,
deleted: getCellsFilesPath() === RECYCLE_BIN_PATH,
@@ -94,7 +95,7 @@ export const useGetAllCellsNodes = ({
}
// cellsRepository and userRepository are not dependencies because they're singletons
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [conversationPath, id, offset, pageSize, setError, setNodes, setPagination, setStatus]);
+ }, [domain, id, offset, pageSize, setError, setNodes, setPagination, setStatus]);
const handleHashChange = useCallback((): void => {
if (enabled !== true) {
diff --git a/apps/webapp/src/script/repositories/cells/cellsRepository.ts b/apps/webapp/src/script/repositories/cells/cellsRepository.ts
index 416743b8a74..2050f598f3d 100644
--- a/apps/webapp/src/script/repositories/cells/cellsRepository.ts
+++ b/apps/webapp/src/script/repositories/cells/cellsRepository.ts
@@ -239,6 +239,7 @@ export class CellsRepository {
async searchNodes({
query,
+ recursive,
limit = DEFAULT_MAX_FILES_LIMIT,
offset = 0,
tags,
@@ -252,6 +253,7 @@ export class CellsRepository {
deleted = false,
}: {
query: string;
+ recursive?: boolean;
limit?: number;
offset?: number;
tags?: string[];
@@ -266,6 +268,7 @@ export class CellsRepository {
}) {
return this.apiClient.api.cells.searchNodes({
phrase: query,
+ recursive,
limit,
offset,
sortBy,
diff --git a/libraries/api-client/src/cells/cellsApi.test.ts b/libraries/api-client/src/cells/cellsApi.test.ts
index 4c363d96a7c..ad4123656b8 100644
--- a/libraries/api-client/src/cells/cellsApi.test.ts
+++ b/libraries/api-client/src/cells/cellsApi.test.ts
@@ -1873,7 +1873,7 @@ describe('CellsAPI', () => {
expect(result).toEqual(mockResponse);
});
- it('handles empty search phrase', async () => {
+ it('omits the Text filter and lists non-recursively for an empty search phrase', async () => {
const searchPhrase = '';
const mockResponse: RestNodeCollection = {
Nodes: [],
@@ -1884,9 +1884,8 @@ describe('CellsAPI', () => {
const result = await cellsAPI.searchNodes({phrase: searchPhrase});
expect(mockNodeServiceApi.lookup).toHaveBeenCalledWith({
- Scope: {Root: {Path: '/'}, Recursive: true},
+ Scope: {Root: {Path: '/'}, Recursive: false},
Filters: {
- Text: {SearchIn: 'BaseName', Term: searchPhrase},
Type: 'UNKNOWN',
Status: {
Deleted: 'Not',
@@ -1896,10 +1895,25 @@ describe('CellsAPI', () => {
Flags: ['WithPreSignedURLs'],
Limit: '10',
Offset: '0',
+ SortField: undefined,
+ SortDirDesc: undefined,
});
expect(result).toEqual(mockResponse);
});
+ it('recurses for an empty phrase when recursive is explicitly requested', async () => {
+ const mockResponse: RestNodeCollection = {Nodes: []} as RestNodeCollection;
+ mockNodeServiceApi.lookup.mockResolvedValueOnce(createMockResponse(mockResponse));
+
+ await cellsAPI.searchNodes({phrase: '', recursive: true});
+
+ expect(mockNodeServiceApi.lookup).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Scope: {Root: {Path: '/'}, Recursive: true},
+ }),
+ );
+ });
+
it('filters by tags when provided', async () => {
const searchPhrase = 'test';
const tags = ['tag1', 'tag2'];
diff --git a/libraries/api-client/src/cells/cellsApi.ts b/libraries/api-client/src/cells/cellsApi.ts
index 2e3749f08cf..8ddf2b02d89 100644
--- a/libraries/api-client/src/cells/cellsApi.ts
+++ b/libraries/api-client/src/cells/cellsApi.ts
@@ -419,6 +419,7 @@ export class CellsAPI {
async searchNodes({
phrase,
path = '/',
+ recursive,
limit = DEFAULT_LIMIT,
offset = DEFAULT_OFFSET,
sortBy,
@@ -432,6 +433,7 @@ export class CellsAPI {
}: {
phrase: string;
path?: string;
+ recursive?: boolean;
limit?: number;
offset?: number;
sortBy?: string;
@@ -450,10 +452,15 @@ export class CellsAPI {
const mimeOp: 'Should' | 'Must' = mimeTypes !== undefined && mimeTypes.length > 1 ? 'Should' : 'Must';
const creatorOp: 'Should' | 'Must' = creatorIds !== undefined && creatorIds.length > 1 ? 'Should' : 'Must';
+ // `searchTerm == nil` drops the Text filter and sets `recursive = false`,
+ // making the empty search view behave exactly like the browse listing (folders-first natural order).
+ const hasPhrase = phrase.length > 0;
+ const isRecursive = recursive ?? hasPhrase;
+
const request: RestLookupRequest = {
- Scope: {Root: {Path: path}, Recursive: true},
+ Scope: {Root: {Path: path}, Recursive: isRecursive},
Filters: {
- Text: {SearchIn: 'BaseName', Term: phrase},
+ ...(hasPhrase ? {Text: {SearchIn: 'BaseName', Term: phrase}} : {}),
Type: type || 'UNKNOWN',
Status: {
Deleted: deleted ? 'Only' : 'Not',