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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,8 @@
"cells.options.share": "Share via link",
"cells.options.tags": "Add or Remove Tags",
"cells.options.versionHistory": "Version History",
"cells.pagination.loadMoreError.heading": "Couldn't load more files",
"cells.pagination.loadMoreError.retry": "Retry",
"cells.pagination.loadMoreResults": "Load More Items",
"cells.pagination.nextPage": "Next Page",
"cells.pagination.previousPage": "Previous Page",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ export const wrapperStyles: CSSObject = {
flexDirection: 'column',
height: '100%',
};

export const loadMoreWrapperStyles: CSSObject = {
padding: '20px',
display: 'flex',
justifyContent: 'center',
};

export const loadMoreErrorWrapperStyles: CSSObject = {
padding: '20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
};

export const loadMoreErrorMessageStyles: CSSObject = {
fontSize: '14px',
color: 'var(--text-color-secondary)',
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {memo, useCallback, useEffect, useRef} from 'react';

import {CONVERSATION_CELLS_STATE} from '@wireapp/api-client/lib/conversation';

import {Button, ButtonVariant} from '@wireapp/react-ui-kit';

import {CellsRepository} from 'Repositories/cells/cellsRepository';
import {ConversationRepository} from 'Repositories/conversation/ConversationRepository';
import {Conversation} from 'Repositories/entity/Conversation';
Expand All @@ -33,11 +35,16 @@ import {CellsLoader} from './CellsLoader/CellsLoader';
import {CellsPagination} from './CellsPagination/CellsPagination';
import {CellsStateInfo} from './CellsStateInfo/CellsStateInfo';
import {CellsTable} from './CellsTable/CellsTable';
import {hasActiveConversationDriveFilters} from './common/driveFilters/driveFilters';
import {getLoadMoreOffset} from './common/loadMorePagination/loadMorePagination';
import {isInRecycleBin} from './common/recycleBin/recycleBin';
import {useCellsStore} from './common/useCellsStore/useCellsStore';
import {useConversationDriveFilters} from './common/useConversationDriveFilters/useConversationDriveFilters';
import {wrapperStyles} from './ConversationCells.styles';
import {
loadMoreErrorMessageStyles,
loadMoreErrorWrapperStyles,
loadMoreWrapperStyles,
wrapperStyles,
} from './ConversationCells.styles';
import {useCellsPagination} from './useCellsPagination/useCellsPagination';
import {useConversationSearchFiles} from './useConversationSearch/useConversationSearchFiles';
import {useGetAllCellsNodes} from './useGetAllCellsNodes/useGetAllCellsNodes';
Expand Down Expand Up @@ -67,7 +74,7 @@ export const ConversationCells = memo(
const {fireAndForgetInvoker} = useApplicationContext();
const {cellsState: initialCellState, name} = useKoSubscribableChildren(activeConversation, ['cellsState', 'name']);

const {getNodes, status: nodesStatus, getPagination} = useCellsStore();
const {getNodes, status: nodesStatus, getPagination, error: storeError} = useCellsStore();

const conversationId = activeConversation.id;
const conversationQualifiedId = activeConversation.qualifiedId;
Expand All @@ -94,12 +101,13 @@ export const ConversationCells = memo(
cellsRepository,
conversationRepository,
});
const hasActiveFilters = hasActiveConversationDriveFilters(filterState);

const {
searchValue,
handleSearch,
handleReload,
handleClearSearch: clearSearch,
loadMore: loadMoreSearchResults,
} = useConversationSearchFiles({
cellsRepository,
conversationQualifiedId,
Expand All @@ -110,27 +118,37 @@ export const ConversationCells = memo(
onClear: refresh,
});

const trimmedSearchValue = searchValue.trim();
const isSearchActive = !!trimmedSearchValue;
// Search view open ⇒ load-more UI + search-hook data; closed ⇒ page-nav UI + browse-hook data.
// The mode is owned by the view, not by whether the user has typed/filtered yet.
const isInSearchMode = isSearchViewOpen;
const wasSearchViewOpen = useRef(isSearchViewOpen);

const handleClearSearch = useCallback((): void => {
clearSearch();
}, [clearSearch]);

useEffect(() => {
if (wasSearchViewOpen.current && !isSearchViewOpen && (isSearchActive || hasActiveFilters)) {
if (wasSearchViewOpen.current && !isSearchViewOpen) {
// Search view just closed — reset any active search/filter and restore the
// browse-mode dataset (handled by clearSearch's onClear callback → refresh).
clearAllFilters();
clearSearch({preserveFilters: false});
}
wasSearchViewOpen.current = isSearchViewOpen;
}, [clearAllFilters, clearSearch, hasActiveFilters, isSearchActive, isSearchViewOpen]);
}, [clearAllFilters, clearSearch, isSearchViewOpen]);

const handleRefresh = useCallback((): void => {
if (isInSearchMode) {
fireAndForgetInvoker.fireAndForget(handleReload);
return;
}

// When search is active, refresh should trigger search reload
const handleRefresh = isSearchActive ? () => handleSearch(searchValue) : refresh;
fireAndForgetInvoker.fireAndForget(refresh);
}, [fireAndForgetInvoker, handleReload, isInSearchMode, refresh]);

const nodes = getNodes({conversationId});
const pagination = getPagination({conversationId});
const loadMoreOffset = getLoadMoreOffset(pagination);

const {goToPage, getPaginationProps} = useCellsPagination({
pagination,
Expand All @@ -139,20 +157,36 @@ export const ConversationCells = memo(
currentNodesCount: nodes.length,
});

useOnPresignedUrlExpired({conversationId, refreshCallback: refresh});
const handleLoadMore = useCallback(async (): Promise<void> => {
if (loadMoreOffset === null) {
return;
}

await loadMoreSearchResults(loadMoreOffset);
}, [loadMoreOffset, loadMoreSearchResults]);

useOnPresignedUrlExpired({conversationId, refreshCallback: handleRefresh});

const isLoading = nodesStatus === 'loading';
const isFetchingMore = nodesStatus === 'fetchingMore';
const isError = nodesStatus === 'error';
const isSuccess = nodesStatus === 'success';

const hasNodes = !!nodes.length;
const emptyView = !isError && !hasNodes && isCellsStateReady;

const isTableVisible = (isSuccess || isLoading) && isCellsStateReady;
const isTableVisible = (isSuccess || isLoading || isFetchingMore) && isCellsStateReady;
const isLoadingVisible = isLoading && isCellsStateReady;
const isNoNodesVisible = !isLoading && emptyView && !isInRecycleBin();
const isPaginationVisible = !emptyView;
const isEmptyRecycleBin = isInRecycleBin() && emptyView && !isLoading;
const isFetchingMoreVisible = isFetchingMore && isCellsStateReady && hasNodes;
const isNoNodesVisible = !isLoading && !isFetchingMore && emptyView && !isInRecycleBin();
const isEmptyRecycleBin = isInRecycleBin() && emptyView && !isLoading && !isFetchingMore;
const hasMorePages = loadMoreOffset !== null;
const hasAppendError = isSuccess && hasNodes && storeError !== null;

const isPaginationVisible = !isInSearchMode && !emptyView;
const isLoadMoreVisible =
isInSearchMode && !isLoading && !isFetchingMore && !emptyView && isSuccess && hasMorePages && !hasAppendError;
const isLoadMoreErrorVisible = isInSearchMode && hasAppendError && hasMorePages;

return (
<div css={wrapperStyles}>
Expand Down Expand Up @@ -184,9 +218,24 @@ export const ConversationCells = memo(
<CellsStateInfo heading={t('cells.noNodes.heading')} description={t('cells.noNodes.description')} />
)}
{isEmptyRecycleBin && <CellsStateInfo description={t('cells.emptyRecycleBin.description')} />}
{(isLoadingVisible || isRefreshing) && <CellsLoader />}
{(isLoadingVisible || isRefreshing || isFetchingMoreVisible) && <CellsLoader />}
{isError && <CellsStateInfo heading={t('cells.error.heading')} description={t('cells.error.description')} />}
{isPaginationVisible && <CellsPagination {...getPaginationProps()} goToPage={goToPage} />}
{isLoadMoreVisible && (
<div css={loadMoreWrapperStyles}>
<Button variant={ButtonVariant.TERTIARY} onClick={handleLoadMore}>
{t('cells.pagination.loadMoreResults')}
</Button>
</div>
)}
{isLoadMoreErrorVisible && (
<div css={loadMoreErrorWrapperStyles} role="alert">
<span css={loadMoreErrorMessageStyles}>{t('cells.pagination.loadMoreError.heading')}</span>
<Button variant={ButtonVariant.TERTIARY} onClick={handleLoadMore}>
{t('cells.pagination.loadMoreError.retry')}
</Button>
</div>
)}
</div>
);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 {getLoadMoreOffset} from './loadMorePagination';

describe('getLoadMoreOffset', () => {
it('returns null when pagination is missing', () => {
expect(getLoadMoreOffset(null)).toBeNull();
});

it('returns null when next offset is missing', () => {
expect(getLoadMoreOffset({currentPage: 1, totalPages: 2})).toBeNull();
});

it('returns next offset when page count is not available', () => {
expect(getLoadMoreOffset({nextOffset: 30})).toBe(30);
});

it('returns next offset when another page is available', () => {
expect(getLoadMoreOffset({nextOffset: 50, currentPage: 1, totalPages: 2})).toBe(50);
});

it('returns null when the current page is the last page', () => {
expect(getLoadMoreOffset({nextOffset: 50, currentPage: 2, totalPages: 2})).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 {CellPagination} from '../cellPagination/cellPagination';

export const LOAD_MORE_INITIAL_SIZE = 30;
export const LOAD_MORE_INCREMENT = 20;

export const getLoadMoreOffset = (pagination: CellPagination | null): number | null => {
const nextOffset = pagination?.nextOffset;

if (nextOffset === undefined) {
return null;
}

const currentPage = pagination?.currentPage;
const totalPages = pagination?.totalPages;

if (currentPage === undefined || totalPages === undefined) {
return nextOffset;
}

return currentPage < totalPages ? nextOffset : null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {CellNode} from 'src/script/types/cellNode';

import {CellPagination} from '../cellPagination/cellPagination';

type Status = 'idle' | 'loading' | 'success' | 'error';
type Status = 'idle' | 'loading' | 'fetchingMore' | 'success' | 'error';

const DEFAULT_PAGE_SIZE = 50;

Expand All @@ -35,6 +35,7 @@ interface CellsState {
pageSize: number;
setPageSize: (pageSize: number) => void;
setNodes: (params: {conversationId: string; nodes: CellNode[]}) => void;
appendNodes: (params: {conversationId: string; nodes: CellNode[]}) => void;
setPagination: (params: {conversationId: string; pagination: CellPagination | null}) => void;
setStatus: (status: Status) => void;
setError: (error: Error | null) => void;
Expand All @@ -51,14 +52,21 @@ export const useCellsStore = create<CellsState>((set, get) => ({
status: 'idle',
error: null,
pageSize: DEFAULT_PAGE_SIZE,
setPageSize: pageSize => set({pageSize}),
setNodes: ({conversationId, nodes}) =>
set(state => ({
nodesByConversation: {
...state.nodesByConversation,
[conversationId]: nodes,
},
})),
setPageSize: pageSize => set({pageSize}),
appendNodes: ({conversationId, nodes}) =>
set(state => ({
nodesByConversation: {
...state.nodesByConversation,
[conversationId]: [...(state.nodesByConversation[conversationId] ?? []), ...nodes],
},
})),
setPagination: ({conversationId, pagination}) =>
set(state => ({
paginationByConversation: {
Expand Down Expand Up @@ -93,8 +101,15 @@ export const useCellsStore = create<CellsState>((set, get) => ({
clearAll: ({conversationId}) => {
const state = get();
const updatedNodesByConversation = {...state.nodesByConversation};
const updatedPaginationByConversation = {...state.paginationByConversation};
delete updatedNodesByConversation[conversationId];
set({nodesByConversation: updatedNodesByConversation, status: 'idle', error: null});
delete updatedPaginationByConversation[conversationId];
set({
nodesByConversation: updatedNodesByConversation,
paginationByConversation: updatedPaginationByConversation,
status: 'idle',
error: null,
});
},
getNodes: ({conversationId}) => {
const state = get().nodesByConversation;
Expand Down
Loading
Loading