snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
170
apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx
Normal file
170
apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, ModalBackdrop, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
Contextualbar,
|
||||
ContextualbarHeader,
|
||||
ContextualbarAction,
|
||||
ContextualbarActions,
|
||||
ContextualbarClose,
|
||||
ContextualbarBack,
|
||||
ContextualbarInnerContent,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import {
|
||||
useLayoutContextualBarExpanded,
|
||||
useToastMessageDispatch,
|
||||
useTranslation,
|
||||
useUserId,
|
||||
useRoomToolbox,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import ThreadChat from './components/ThreadChat';
|
||||
import ThreadSkeleton from './components/ThreadSkeleton';
|
||||
import ThreadTitle from './components/ThreadTitle';
|
||||
import { useThreadMainMessageQuery } from './hooks/useThreadMainMessageQuery';
|
||||
import { useToggleFollowingThreadMutation } from './hooks/useToggleFollowingThreadMutation';
|
||||
import { useGoToThreadList } from '../../hooks/useGoToThreadList';
|
||||
import ChatProvider from '../../providers/ChatProvider';
|
||||
|
||||
export type ThreadProps = {
|
||||
tmid: IMessage['_id'];
|
||||
};
|
||||
|
||||
const Thread = ({ tmid }: ThreadProps) => {
|
||||
const goToThreadList = useGoToThreadList({ replace: true });
|
||||
const { closeTab } = useRoomToolbox();
|
||||
|
||||
const mainMessageQueryResult = useThreadMainMessageQuery(tmid, {
|
||||
onDelete: () => {
|
||||
closeTab();
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const canExpand = useLayoutContextualBarExpanded();
|
||||
const [expanded, setExpanded] = useLocalStorage('expand-threads', false);
|
||||
|
||||
const uid = useUserId();
|
||||
const following = uid ? (mainMessageQueryResult.data?.replies?.includes(uid) ?? false) : false;
|
||||
const toggleFollowingMutation = useToggleFollowingThreadMutation({
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
});
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
goToThreadList();
|
||||
};
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setExpanded((expanded) => !expanded);
|
||||
};
|
||||
|
||||
const handleToggleFollowing = () => {
|
||||
const rid = mainMessageQueryResult.data?.rid;
|
||||
|
||||
if (!rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleFollowingMutation.mutate({ rid, tmid, follow: !following });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const isExpanded = canExpand && expanded;
|
||||
const portalTarget = isExpanded ? document.getElementById('main-content') : null;
|
||||
|
||||
const threadContent = (
|
||||
<Contextualbar
|
||||
rcx-thread-view
|
||||
className={
|
||||
isExpanded
|
||||
? css`
|
||||
max-width: 855px !important;
|
||||
@media (min-width: 780px) and (max-width: 1135px) {
|
||||
max-width: calc(100% - var(--sidebar-width)) !important;
|
||||
}
|
||||
`
|
||||
: undefined
|
||||
}
|
||||
position='absolute'
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
width='full'
|
||||
overflow='hidden'
|
||||
zIndex={100}
|
||||
insetBlock={0}
|
||||
border='none'
|
||||
>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarBack onClick={handleGoBack} />
|
||||
{(mainMessageQueryResult.isLoading && <Skeleton width='100%' />) ||
|
||||
(mainMessageQueryResult.isSuccess && <ThreadTitle mainMessage={mainMessageQueryResult.data} />) ||
|
||||
null}
|
||||
<ContextualbarActions>
|
||||
{canExpand && (
|
||||
<ContextualbarAction
|
||||
name={expanded ? 'arrow-collapse' : 'arrow-expand'}
|
||||
title={expanded ? t('Collapse') : t('Expand')}
|
||||
onClick={handleToggleExpand}
|
||||
/>
|
||||
)}
|
||||
<ContextualbarAction
|
||||
name={following ? 'bell' : 'bell-off'}
|
||||
title={following ? t('Following') : t('Not_Following')}
|
||||
disabled={!mainMessageQueryResult.isSuccess || toggleFollowingMutation.isPending}
|
||||
onClick={handleToggleFollowing}
|
||||
/>
|
||||
<ContextualbarClose onClick={handleClose} />
|
||||
</ContextualbarActions>
|
||||
</ContextualbarHeader>
|
||||
|
||||
{(mainMessageQueryResult.isLoading && <ThreadSkeleton />) ||
|
||||
(mainMessageQueryResult.isSuccess && (
|
||||
<ChatProvider tmid={tmid}>
|
||||
<ThreadChat mainMessage={mainMessageQueryResult.data} />
|
||||
</ChatProvider>
|
||||
)) ||
|
||||
null}
|
||||
</Contextualbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarInnerContent>
|
||||
{portalTarget ? (
|
||||
createPortal(
|
||||
<>
|
||||
<ModalBackdrop
|
||||
className={css`
|
||||
position: absolute !important;
|
||||
`}
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
{threadContent}
|
||||
</>,
|
||||
portalTarget,
|
||||
)
|
||||
) : (
|
||||
<Box flexGrow={1} position='relative'>
|
||||
{threadContent}
|
||||
</Box>
|
||||
)}
|
||||
</ContextualbarInnerContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thread;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
import ThreadList from './ThreadList';
|
||||
import { createFakeRoom } from '../../../../../tests/mocks/data';
|
||||
|
||||
const fakeRoom = createFakeRoom({ t: 'c' });
|
||||
|
||||
jest.mock('./components/ThreadListItem', () => <></>);
|
||||
jest.mock('../../contexts/RoomContext', () => ({
|
||||
useRoom: () => fakeRoom,
|
||||
useRoomSubscription: () => fakeRoom,
|
||||
}));
|
||||
|
||||
// TODO: Create a function to mock the lib/i18n to be used with mockAppRoot
|
||||
jest.mock('../../../../../app/utils/lib/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('ThreadList Component', () => {
|
||||
it('should display an error message when in error state', async () => {
|
||||
render(<ThreadList />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withRoom(fakeRoom)
|
||||
.withEndpoint(
|
||||
'GET',
|
||||
'/v1/chat.getThreadsList',
|
||||
jest.fn().mockRejectedValue({ success: false, error: 'error-not-allowed', errorType: 'error-not-allowed' }),
|
||||
)
|
||||
.wrap((children) => (
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>{children}</VirtuosoMockContext.Provider>
|
||||
))
|
||||
.build(),
|
||||
});
|
||||
expect(await screen.findByText('error-not-allowed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { Box, Icon, TextInput, Select, Callout, Throbber } from '@rocket.chat/fuselage';
|
||||
import { useResizeObserver, useAutoFocus, useLocalStorage, useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
VirtualizedScrollbars,
|
||||
ContextualbarClose,
|
||||
ContextualbarContent,
|
||||
ContextualbarHeader,
|
||||
ContextualbarIcon,
|
||||
ContextualbarTitle,
|
||||
ContextualbarEmptyContent,
|
||||
ContextualbarSection,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation, useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useMemo, useState, useCallback, useId } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import ThreadListItem from './components/ThreadListItem';
|
||||
import { useThreadsList } from './hooks/useThreadsList';
|
||||
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
|
||||
import { getErrorMessage } from '../../../../lib/errorHandling';
|
||||
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
|
||||
import { useGoToThread } from '../../hooks/useGoToThread';
|
||||
|
||||
type ThreadType = 'all' | 'following' | 'unread';
|
||||
|
||||
// TODO: Refactor this component to isolate the data from the visual
|
||||
const ThreadList = () => {
|
||||
const t = useTranslation();
|
||||
const threadListId = useId();
|
||||
|
||||
const { closeTab } = useRoomToolbox();
|
||||
|
||||
const handleTabBarCloseButtonClick = useCallback(() => {
|
||||
closeTab();
|
||||
}, [closeTab]);
|
||||
|
||||
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver<HTMLElement>({
|
||||
debounceDelay: 200,
|
||||
});
|
||||
|
||||
const autoFocusRef = useAutoFocus<HTMLInputElement>(true);
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const handleSearchTextChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(event.currentTarget.value);
|
||||
},
|
||||
[setSearchText],
|
||||
);
|
||||
|
||||
const typeOptions: (readonly [type: ThreadType, label: string])[] = useMemo(
|
||||
() => [
|
||||
['all', t('All')],
|
||||
['following', t('Following')],
|
||||
['unread', t('Unread')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const [type, setType] = useLocalStorage<ThreadType>('thread-list-type', 'all');
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(type: string) => {
|
||||
const typeOption = typeOptions.find(([t]) => t === type);
|
||||
if (typeOption) setType(typeOption[0]);
|
||||
},
|
||||
[setType, typeOptions],
|
||||
);
|
||||
|
||||
const room = useRoom();
|
||||
const rid = room._id;
|
||||
const subscription = useRoomSubscription();
|
||||
const subscribed = !!subscription;
|
||||
const uid = useUserId();
|
||||
const tunread = subscription?.tunread?.sort().join(',');
|
||||
const text = useDebouncedValue(searchText, 400);
|
||||
const options = useDebouncedValue(
|
||||
useMemo(() => {
|
||||
if (type === 'all' || !subscribed || !uid) {
|
||||
return {
|
||||
rid,
|
||||
text,
|
||||
};
|
||||
}
|
||||
switch (type) {
|
||||
case 'following':
|
||||
return {
|
||||
rid,
|
||||
text,
|
||||
type,
|
||||
uid,
|
||||
};
|
||||
case 'unread':
|
||||
return {
|
||||
rid,
|
||||
text,
|
||||
type,
|
||||
tunread: tunread?.split(','),
|
||||
};
|
||||
}
|
||||
}, [rid, subscribed, text, tunread, type, uid]),
|
||||
300,
|
||||
);
|
||||
|
||||
const { isPending, error, isSuccess, data, fetchNextPage } = useThreadsList(options);
|
||||
|
||||
const items = data?.items || [];
|
||||
const itemCount = data?.itemCount ?? 0;
|
||||
|
||||
const goToThread = useGoToThread({ replace: true });
|
||||
const handleThreadClick = useCallback(
|
||||
(tmid: IMessage['_id']) => {
|
||||
goToThread({ rid, tmid });
|
||||
},
|
||||
[rid, goToThread],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarIcon name='thread' />
|
||||
<ContextualbarTitle>{t('Threads')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={handleTabBarCloseButtonClick} />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarSection>
|
||||
<TextInput
|
||||
aria-label={t('Search_Messages')}
|
||||
aria-controls={isSuccess ? threadListId : undefined}
|
||||
placeholder={t('Search_Messages')}
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
ref={autoFocusRef}
|
||||
value={searchText}
|
||||
onChange={handleSearchTextChange}
|
||||
/>
|
||||
<Box width='x144' marginInlineStart={8}>
|
||||
<Select
|
||||
aria-controls={isSuccess ? threadListId : undefined}
|
||||
options={typeOptions}
|
||||
value={type}
|
||||
onChange={(value) => handleTypeChange(String(value))}
|
||||
/>
|
||||
</Box>
|
||||
</ContextualbarSection>
|
||||
<ContextualbarContent paddingInline={0} ref={ref}>
|
||||
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={itemCount} />
|
||||
{isPending && (
|
||||
<Box paddingInline={24} paddingBlock={12}>
|
||||
<Throbber size='x12' />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Callout marginInline={24} type='danger'>
|
||||
{getErrorMessage(error, t('Something_went_wrong'))}
|
||||
</Callout>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<Box id={threadListId} width='full' height='full' overflow='hidden' flexShrink={1}>
|
||||
{items.length === 0 && <ContextualbarEmptyContent title={t('No_Threads')} />}
|
||||
{items.length > 0 && (
|
||||
<VirtualizedScrollbars>
|
||||
<Virtuoso
|
||||
style={{
|
||||
height: blockSize,
|
||||
width: inlineSize,
|
||||
}}
|
||||
totalCount={itemCount}
|
||||
endReached={() => fetchNextPage()}
|
||||
overscan={25}
|
||||
data={items}
|
||||
itemContent={(_index, data: IThreadMainMessage) => (
|
||||
<ThreadListItem
|
||||
thread={data}
|
||||
unread={subscription?.tunread ?? []}
|
||||
unreadUser={subscription?.tunreadUser ?? []}
|
||||
unreadGroup={subscription?.tunreadGroup ?? []}
|
||||
hasDraft={Boolean(subscription?.threadDrafts?.[data._id])}
|
||||
onClick={handleThreadClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VirtualizedScrollbars>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ContextualbarContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadList;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import Thread from './Thread';
|
||||
import ThreadList from './ThreadList';
|
||||
|
||||
const Threads = () => {
|
||||
const { context: tmid } = useRoomToolbox();
|
||||
|
||||
if (tmid) {
|
||||
return <Thread tmid={tmid} />;
|
||||
}
|
||||
|
||||
return <ThreadList />;
|
||||
};
|
||||
|
||||
export default Threads;
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { isEditedMessage, isThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
|
||||
import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useState, useEffect, useCallback, useId } from 'react';
|
||||
|
||||
import ThreadMessageList from './ThreadMessageList';
|
||||
import MessageListErrorBoundary from '../../../MessageList/MessageListErrorBoundary';
|
||||
import DropTargetOverlay from '../../../body/DropTargetOverlay';
|
||||
import { useFileUploadDropTarget } from '../../../body/hooks/useFileUploadDropTarget';
|
||||
import ComposerContainer from '../../../composer/ComposerContainer';
|
||||
import RoomComposer from '../../../composer/RoomComposer/RoomComposer';
|
||||
import { useChat } from '../../../contexts/ChatContext';
|
||||
import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext';
|
||||
import { DateListProvider } from '../../../providers/DateListProvider';
|
||||
|
||||
export type ThreadChatProps = {
|
||||
mainMessage: IThreadMainMessage;
|
||||
};
|
||||
|
||||
const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
|
||||
const chat = useChat();
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('No ChatContext provided');
|
||||
}
|
||||
|
||||
const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel');
|
||||
|
||||
const [sendToChannel, setSendToChannel] = useState(() => {
|
||||
switch (sendToChannelPreference) {
|
||||
case 'always':
|
||||
return true;
|
||||
case 'never':
|
||||
return false;
|
||||
default:
|
||||
return !mainMessage.tcount;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSend = useCallback((): void => {
|
||||
if (sendToChannelPreference === 'default') {
|
||||
setSendToChannel(false);
|
||||
}
|
||||
}, [sendToChannelPreference]);
|
||||
|
||||
const { closeTab } = useRoomToolbox();
|
||||
|
||||
const handleComposerEscape = useCallback((): void => {
|
||||
closeTab();
|
||||
}, [closeTab]);
|
||||
|
||||
const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget();
|
||||
|
||||
const handleNavigateToPreviousMessage = useCallback((): void => {
|
||||
chat?.messageEditing.toPreviousMessage();
|
||||
}, [chat?.messageEditing]);
|
||||
|
||||
const handleNavigateToNextMessage = useCallback((): void => {
|
||||
chat?.messageEditing.toNextMessage();
|
||||
}, [chat?.messageEditing]);
|
||||
|
||||
const room = useRoom();
|
||||
const readThread = useEndpoint('POST', '/v1/chat.readThread');
|
||||
useEffect(() => {
|
||||
clientCallbacks.add(
|
||||
'streamNewMessage',
|
||||
(msg: IMessage) => {
|
||||
if (room._id !== msg.rid || isEditedMessage(msg) || msg.tmid !== mainMessage._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(readThread({ tmid: mainMessage._id })).catch(() => undefined);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
`thread-${room._id}`,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('streamNewMessage', `thread-${room._id}`);
|
||||
};
|
||||
}, [mainMessage._id, readThread, room._id]);
|
||||
|
||||
const subscription = useRoomSubscription();
|
||||
const sendToChannelID = useId();
|
||||
const t = useTranslation();
|
||||
|
||||
const [shouldJumpToBottom, setShouldJumpToBottom] = useState(true);
|
||||
|
||||
return (
|
||||
<ContextualbarContent flexShrink={1} flexGrow={1} paddingInline={0} {...fileUploadTriggerProps}>
|
||||
<DateListProvider>
|
||||
<DropTargetOverlay {...fileUploadOverlayProps} />
|
||||
<Box
|
||||
is='section'
|
||||
position='relative'
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
flexBasis='auto'
|
||||
height='full'
|
||||
>
|
||||
<MessageListErrorBoundary>
|
||||
<ThreadMessageList
|
||||
mainMessage={mainMessage}
|
||||
shouldJumpToBottom={shouldJumpToBottom}
|
||||
setShouldJumpToBottom={setShouldJumpToBottom}
|
||||
/>
|
||||
</MessageListErrorBoundary>
|
||||
|
||||
<RoomComposer aria-label={t('Thread_composer')}>
|
||||
<ComposerContainer
|
||||
tmid={mainMessage._id}
|
||||
threadExists={isThreadMainMessage(mainMessage)}
|
||||
subscription={subscription}
|
||||
onSend={handleSend}
|
||||
onEscape={handleComposerEscape}
|
||||
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
|
||||
onNavigateToNextMessage={handleNavigateToNextMessage}
|
||||
tshow={sendToChannel}
|
||||
>
|
||||
<Field marginBlock={8}>
|
||||
<FieldRow justifyContent='initial'>
|
||||
<CheckBox
|
||||
id={sendToChannelID}
|
||||
checked={sendToChannel}
|
||||
onChange={() => setSendToChannel((checked) => !checked)}
|
||||
name='alsoSendThreadToChannel'
|
||||
/>
|
||||
<FieldLabel marginInlineStart='x8' htmlFor={sendToChannelID} color='annotation' fontScale='p2'>
|
||||
{t('Also_send_to_channel')}
|
||||
</FieldLabel>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</ComposerContainer>
|
||||
</RoomComposer>
|
||||
</Box>
|
||||
</DateListProvider>
|
||||
</ContextualbarContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadChat;
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ThreadListMessage from './ThreadListMessage';
|
||||
import { useDecryptedMessage } from '../../../../../hooks/useDecryptedMessage';
|
||||
import { normalizeThreadMessage } from '../../../../../lib/normalizeThreadMessage';
|
||||
|
||||
export type ThreadListItemProps = {
|
||||
thread: IThreadMainMessage;
|
||||
unread: string[];
|
||||
unreadUser: string[];
|
||||
unreadGroup: string[];
|
||||
hasDraft?: boolean;
|
||||
onClick: (tmid: IThreadMainMessage['_id']) => void;
|
||||
};
|
||||
|
||||
const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, hasDraft, onClick }: ThreadListItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const uid = useUserId();
|
||||
const decryptedMsg = useDecryptedMessage(thread);
|
||||
const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }, t);
|
||||
|
||||
const { name = thread.u.username } = thread.u;
|
||||
|
||||
const following = !!uid && (thread.replies?.includes(uid) ?? false);
|
||||
|
||||
const showRealNames = (useSetting('UI_Use_Real_Name') as boolean | undefined) ?? false;
|
||||
|
||||
const handleListItemClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick(thread._id);
|
||||
},
|
||||
[onClick, thread._id],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThreadListMessage
|
||||
className={css`
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${Palette.surface['surface-hover']};
|
||||
}
|
||||
border-bottom: 1px solid ${Palette.stroke['stroke-extra-light']} !important;
|
||||
`}
|
||||
tabIndex={0}
|
||||
_id={thread._id}
|
||||
replies={thread.tcount ?? 0}
|
||||
tlm={thread.tlm}
|
||||
ts={thread.ts}
|
||||
participants={thread.replies}
|
||||
name={showRealNames ? name : thread.u.username}
|
||||
username={thread.u.username}
|
||||
unread={unread.includes(thread._id)}
|
||||
mention={unreadUser.includes(thread._id)}
|
||||
all={unreadGroup.includes(thread._id)}
|
||||
following={following}
|
||||
hasDraft={hasDraft}
|
||||
data-id={thread._id}
|
||||
msg={msg ?? ''}
|
||||
rid={thread.rid}
|
||||
onClick={handleListItemClick}
|
||||
emoji={thread?.emoji}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ThreadListItem);
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
Message,
|
||||
MessageLeftContainer,
|
||||
MessageContainer,
|
||||
MessageHeader,
|
||||
MessageName,
|
||||
MessageTimestamp,
|
||||
MessageBody,
|
||||
MessageContainerFixed,
|
||||
MessageStatusIndicatorItem,
|
||||
Box,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { MessageAvatar } from '@rocket.chat/ui-avatar';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ThreadListMetrics from './ThreadListMetrics';
|
||||
import Emoji from '../../../../../components/Emoji';
|
||||
import ThreadMetricsFollow from '../../../../../components/message/content/ThreadMetricsFollow';
|
||||
import ThreadMetricsUnreadBadge from '../../../../../components/message/content/ThreadMetricsUnreadBadge';
|
||||
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
|
||||
|
||||
export type ThreadListMessageProps = {
|
||||
_id: IMessage['_id'];
|
||||
msg: ReactNode;
|
||||
following: boolean;
|
||||
username: IMessage['u']['username'];
|
||||
name?: IMessage['u']['name'];
|
||||
ts: IMessage['ts'];
|
||||
replies: number;
|
||||
participants: string[] | undefined;
|
||||
rid: IMessage['rid'];
|
||||
unread: boolean;
|
||||
mention: boolean;
|
||||
all: boolean;
|
||||
tlm: Date;
|
||||
emoji: IMessage['emoji'];
|
||||
hasDraft?: boolean;
|
||||
} & Omit<ComponentProps<typeof Box>, 'is'>;
|
||||
|
||||
const ThreadListMessage = ({
|
||||
_id,
|
||||
msg,
|
||||
following,
|
||||
username,
|
||||
name = username,
|
||||
ts,
|
||||
replies,
|
||||
participants,
|
||||
unread,
|
||||
rid,
|
||||
mention,
|
||||
all,
|
||||
tlm,
|
||||
className = [],
|
||||
emoji,
|
||||
hasDraft,
|
||||
...props
|
||||
}: ThreadListMessageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const formatDate = useTimeAgo();
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Box paddingBlockStart={16} is={Message} {...props}>
|
||||
<MessageLeftContainer>
|
||||
<MessageAvatar emoji={emoji ? <Emoji emojiHandle={emoji} fillContainer /> : undefined} username={username} size='x36' />
|
||||
</MessageLeftContainer>
|
||||
<MessageContainer>
|
||||
<MessageHeader>
|
||||
<MessageName title={username}>{name}</MessageName>
|
||||
<MessageTimestamp>{formatDate(ts)}</MessageTimestamp>
|
||||
{hasDraft && <MessageStatusIndicatorItem name='pencil' title={t('Unfinished_thread_message')} />}
|
||||
</MessageHeader>
|
||||
<MessageBody clamp={2}>{msg}</MessageBody>
|
||||
<ThreadListMetrics lm={tlm} participants={participants || []} counter={replies} />
|
||||
</MessageContainer>
|
||||
<MessageContainerFixed>
|
||||
<ThreadMetricsFollow following={following} mid={_id} rid={rid} mention={false} unread={false} all={false} />
|
||||
{unread && (
|
||||
<Box marginBlockStart={24}>
|
||||
<ThreadMetricsUnreadBadge unread={unread} mention={mention} all={all} />
|
||||
</Box>
|
||||
)}
|
||||
</MessageContainerFixed>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ThreadListMessage);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MessageMetricsItem, MessageBlock, MessageMetrics, MessageMetricsItemIcon, MessageMetricsItemLabel } from '@rocket.chat/fuselage';
|
||||
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
|
||||
import { useTranslation } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import ThreadMetricsParticipants from '../../../../../components/message/content/ThreadMetricsParticipants';
|
||||
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
|
||||
|
||||
type ThreadMetricsProps = {
|
||||
lm: Date;
|
||||
counter: number;
|
||||
participants: Array<string>;
|
||||
};
|
||||
|
||||
const ThreadListMetrics = ({ counter, participants, lm }: ThreadMetricsProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const format = useTimeAgo();
|
||||
|
||||
const { ref, borderBoxSize } = useResizeObserver<HTMLDivElement>();
|
||||
|
||||
const isSmall = (borderBoxSize.inlineSize || Infinity) < 200;
|
||||
|
||||
return (
|
||||
<MessageBlock ref={ref}>
|
||||
<MessageMetrics>
|
||||
{participants?.length > 0 && <ThreadMetricsParticipants participants={participants} />}
|
||||
<MessageMetricsItem title={t('Last_message__date__', { date: format(lm) })}>
|
||||
<MessageMetricsItemIcon name='thread' />
|
||||
{isSmall ? (
|
||||
<MessageMetricsItemLabel>{t('__count__replies', { count: counter })}</MessageMetricsItemLabel>
|
||||
) : (
|
||||
<MessageMetricsItemLabel>{t('__count__replies__date__', { count: counter, date: format(lm) })}</MessageMetricsItemLabel>
|
||||
)}
|
||||
</MessageMetricsItem>
|
||||
</MessageMetrics>
|
||||
</MessageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadListMetrics;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { Box, Bubble, MessageDivider } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SystemMessage from '../../../../../components/message/variants/SystemMessage';
|
||||
import ThreadMessage from '../../../../../components/message/variants/ThreadMessage';
|
||||
import { useFormatDate } from '../../../../../hooks/useFormatDate';
|
||||
import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay';
|
||||
import { useRoomSubscription } from '../../../contexts/RoomContext';
|
||||
import { useDateRef } from '../../../providers/DateListProvider';
|
||||
|
||||
type ThreadMessageProps = {
|
||||
message: IThreadMessage | IThreadMainMessage;
|
||||
previous: IThreadMessage | IThreadMainMessage;
|
||||
sequential: boolean;
|
||||
shouldShowAsSequential: boolean;
|
||||
showUserAvatar: boolean;
|
||||
firstUnread: boolean;
|
||||
system: boolean;
|
||||
};
|
||||
|
||||
export const ThreadMessageItem = ({
|
||||
message,
|
||||
previous,
|
||||
shouldShowAsSequential,
|
||||
showUserAvatar,
|
||||
firstUnread,
|
||||
system,
|
||||
}: ThreadMessageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const formatDate = useFormatDate();
|
||||
const ref = useDateRef();
|
||||
|
||||
const newDay = isMessageNewDay(message, previous);
|
||||
|
||||
const showDivider = newDay || firstUnread;
|
||||
|
||||
const subscription = useRoomSubscription();
|
||||
const ignoredUser = Boolean(subscription?.ignored?.includes(message.u._id) || (message as { ignored?: boolean }).ignored);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDivider && (
|
||||
<Box
|
||||
role='listitem'
|
||||
ref={ref}
|
||||
data-id={message.ts}
|
||||
{...(newDay && {
|
||||
'data-time': new Date(message.ts)
|
||||
.toISOString()
|
||||
.replaceAll(/[-T:.]/g, '')
|
||||
.substring(0, 8),
|
||||
})}
|
||||
>
|
||||
<MessageDivider unreadLabel={firstUnread ? t('Unread_Messages').toLowerCase() : undefined}>
|
||||
{newDay && (
|
||||
<Bubble small secondary>
|
||||
{formatDate(message.ts)}
|
||||
</Bubble>
|
||||
)}
|
||||
</MessageDivider>
|
||||
</Box>
|
||||
)}
|
||||
{system ? (
|
||||
<SystemMessage message={message} showUserAvatar={showUserAvatar} />
|
||||
) : (
|
||||
<ThreadMessage
|
||||
message={message}
|
||||
sequential={shouldShowAsSequential}
|
||||
unread={firstUnread}
|
||||
showUserAvatar={showUserAvatar}
|
||||
ignoredUser={ignoredUser}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { IMessage, IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import ThreadMessageList from './ThreadMessageList';
|
||||
import { createFakeMessage, createFakeRoom } from '../../../../../../tests/mocks/data';
|
||||
import { useThreadMessagesQuery } from '../hooks/useThreadMessagesQuery';
|
||||
|
||||
const room = createFakeRoom({ _id: 'room-id', t: 'c' });
|
||||
const mockVirtualizerHandle = {
|
||||
scrollToIndex: jest.fn(),
|
||||
findItemIndex: jest.fn(() => 0),
|
||||
scrollOffset: 100,
|
||||
scrollSize: 1000,
|
||||
viewportSize: 300,
|
||||
};
|
||||
|
||||
jest.mock('virtua', () => {
|
||||
const { Children, forwardRef, useImperativeHandle } = jest.requireActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
// `virtua` renders a plain container and wraps every child in a `div` of its own, so the list markup
|
||||
// under test cannot rely on `ul`/`li` semantics.
|
||||
VList: forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onScroll,
|
||||
shift: _shift,
|
||||
keepMounted: _keepMounted,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onScroll?: (offset: number) => void;
|
||||
shift?: boolean;
|
||||
keepMounted?: number[];
|
||||
},
|
||||
ref: any,
|
||||
) => {
|
||||
useImperativeHandle(ref, () => mockVirtualizerHandle);
|
||||
return (
|
||||
<div data-testid='thread-message-list' onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)} {...props}>
|
||||
{Children.map(children, (child) => (child ? <div>{child}</div> : child))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@rocket.chat/fuselage-hooks', () => ({
|
||||
...jest.requireActual('@rocket.chat/fuselage-hooks'),
|
||||
useDebouncedCallback: (callback: (...args: any[]) => void) => callback,
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
clientCallbacks: {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
priority: { MEDIUM: 0 },
|
||||
},
|
||||
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
|
||||
{ children, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<div ref={ref}>{children}</div>
|
||||
<div data-testid='overlay-scrollbar-handle' />
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useThreadMessagesQuery', () => ({
|
||||
useThreadMessagesQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useDateScroll', () => ({
|
||||
useDateScroll: () => ({
|
||||
bubbleRef: { current: null },
|
||||
handleDateScroll: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../contexts/RoomContext', () => ({
|
||||
useRoom: () => room,
|
||||
}));
|
||||
|
||||
jest.mock('../../../MessageList/hooks/useKeepAtBottom', () => ({
|
||||
useKeepAtBottom: () => ({ keepAtBottomRef: jest.fn(), setKeepAtBottom: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useFirstUnreadMessageId', () => ({
|
||||
useFirstUnreadMessageId: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useMessageListNavigation', () => ({
|
||||
useMessageListNavigation: () => ({ messageListRef: { current: null } }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../MessageList/providers/MessageListProvider', () => ({ children }: { children: ReactNode }) => <>{children}</>);
|
||||
|
||||
jest.mock('../../../../../lib/utils/setMessageJumpQueryStringParameter', () => ({
|
||||
setMessageJumpQueryStringParameter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./ThreadMessageItem', () => ({
|
||||
ThreadMessageItem: ({ message }: { message: IMessage }) => <div role='listitem'>{message._id}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../../../BubbleDate', () => ({
|
||||
BubbleDate: forwardRef(function BubbleDate(_, ref) {
|
||||
return <time ref={ref as any} />;
|
||||
}),
|
||||
}));
|
||||
|
||||
const createThreadMessage = (index: number): IThreadMessage => {
|
||||
const ts = new Date(Date.UTC(2026, 0, index));
|
||||
|
||||
return createFakeMessage<IThreadMessage>({
|
||||
_id: `reply-${index}`,
|
||||
rid: room._id,
|
||||
tmid: 'thread-id',
|
||||
msg: `reply ${index}`,
|
||||
ts,
|
||||
_updatedAt: ts,
|
||||
u: {
|
||||
_id: 'user-id',
|
||||
username: 'user',
|
||||
name: 'User',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('ThreadMessageList', () => {
|
||||
it('fetches the previous page after a pointer interaction with the overlay scrollbar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchPreviousPage = jest.fn().mockResolvedValue(undefined);
|
||||
const mainMessage = createFakeMessage<IThreadMainMessage>({
|
||||
_id: 'thread-id',
|
||||
rid: room._id,
|
||||
msg: 'main message',
|
||||
tcount: 1,
|
||||
u: {
|
||||
_id: 'user-id',
|
||||
username: 'user',
|
||||
name: 'User',
|
||||
},
|
||||
});
|
||||
(useThreadMessagesQuery as jest.Mock).mockReturnValue({
|
||||
data: { messages: [createThreadMessage(1)] },
|
||||
isLoading: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchPreviousPage,
|
||||
hasPreviousPage: true,
|
||||
isFetchingPreviousPage: false,
|
||||
loadMessageAround: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ThreadMessageList mainMessage={mainMessage} shouldJumpToBottom setShouldJumpToBottom={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withJohnDoe().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true).build(),
|
||||
});
|
||||
|
||||
fireEvent.scroll(screen.getByTestId('thread-message-list'));
|
||||
expect(fetchPreviousPage).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: '[MouseLeft>]', target: screen.getByTestId('overlay-scrollbar-handle') });
|
||||
fireEvent.scroll(screen.getByTestId('thread-message-list'));
|
||||
await user.pointer({ keys: '[/MouseLeft]' });
|
||||
|
||||
expect(fetchPreviousPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadMessageList accessibility', () => {
|
||||
const mainMessage = createFakeMessage<IThreadMainMessage>({
|
||||
_id: 'thread-id',
|
||||
rid: room._id,
|
||||
msg: 'main message',
|
||||
tcount: 1,
|
||||
u: {
|
||||
_id: 'user-id',
|
||||
username: 'user',
|
||||
name: 'User',
|
||||
},
|
||||
});
|
||||
|
||||
const mockThreadMessagesQuery = (overrides: Record<string, unknown> = {}) => {
|
||||
(useThreadMessagesQuery as jest.Mock).mockReturnValue({
|
||||
data: { messages: [createThreadMessage(1), createThreadMessage(2)] },
|
||||
isLoading: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchPreviousPage: jest.fn(),
|
||||
hasPreviousPage: false,
|
||||
isFetchingPreviousPage: false,
|
||||
loadMessageAround: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const renderThreadMessageList = () =>
|
||||
render(<ThreadMessageList mainMessage={mainMessage} shouldJumpToBottom={false} setShouldJumpToBottom={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withJohnDoe().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true).build(),
|
||||
});
|
||||
|
||||
it('should render a labelled list exposing every message as a list item', () => {
|
||||
mockThreadMessagesQuery();
|
||||
|
||||
renderThreadMessageList();
|
||||
|
||||
const list = screen.getByRole('list');
|
||||
|
||||
expect(list).toHaveAccessibleName();
|
||||
// the two replies plus the main message
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const states: [string, Record<string, unknown>][] = [
|
||||
['default', {}],
|
||||
['loading', { isLoading: true, data: undefined }],
|
||||
['loading previous messages', { hasPreviousPage: true, isFetchingPreviousPage: true }],
|
||||
['loading next messages', { hasNextPage: true, isFetchingNextPage: true }],
|
||||
['able to load next messages', { hasNextPage: true }],
|
||||
];
|
||||
|
||||
it.each(states)('should have no accessibility violations when %s', async (_state, overrides) => {
|
||||
mockThreadMessagesQuery(overrides);
|
||||
|
||||
const { container } = renderThreadMessageList();
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { isEditedMessage } from '@rocket.chat/core-typings';
|
||||
import { useDebouncedCallback, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
|
||||
import { useSearchParameter, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { differenceInSeconds } from 'date-fns';
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { VirtualizerHandle } from 'virtua';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { ThreadMessageItem } from './ThreadMessageItem';
|
||||
import InfiniteListAnchor from '../../../../../components/InfiniteListAnchor';
|
||||
import { useMergedRefsV2 } from '../../../../../hooks/useMergedRefsV2';
|
||||
import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter';
|
||||
import { BubbleDate } from '../../../BubbleDate';
|
||||
import { useKeepAtBottom } from '../../../MessageList/hooks/useKeepAtBottom';
|
||||
import { useKeepMountedMessages } from '../../../MessageList/hooks/useKeepMountedMessages';
|
||||
import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay';
|
||||
import MessageListProvider from '../../../MessageList/providers/MessageListProvider';
|
||||
import { clearHighlightMessage, setHighlightMessage } from '../../../MessageList/providers/messageHighlightSubscription';
|
||||
import LoadingMessagesIndicator from '../../../body/LoadingMessagesIndicator';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
import { useDateScroll } from '../../../hooks/useDateScroll';
|
||||
import { useFirstUnreadMessageId } from '../../../hooks/useFirstUnreadMessageId';
|
||||
import { useMessageListNavigation } from '../../../hooks/useMessageListNavigation';
|
||||
import { useThreadMessagesQuery } from '../hooks/useThreadMessagesQuery';
|
||||
import './threads.css';
|
||||
|
||||
const isMessageSequential = (current: IMessage, previous: IMessage | undefined, groupingRange: number): boolean => {
|
||||
if (!previous) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MessageTypes.isSystemMessage(current) || MessageTypes.isSystemMessage(previous)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.groupable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.u._id !== previous.u._id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.alias !== previous.alias) {
|
||||
return false;
|
||||
}
|
||||
return differenceInSeconds(current.ts, previous.ts) < groupingRange && !isMessageNewDay(current, previous);
|
||||
};
|
||||
|
||||
export type ThreadMessageListProps = {
|
||||
mainMessage: IThreadMainMessage;
|
||||
shouldJumpToBottom: boolean;
|
||||
setShouldJumpToBottom: (shouldJumpToBottom: boolean) => void;
|
||||
};
|
||||
|
||||
const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBottom }: ThreadMessageListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const msgJumpParam = useSearchParameter('msg');
|
||||
const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: loading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchPreviousPage,
|
||||
hasPreviousPage,
|
||||
isFetchingPreviousPage,
|
||||
loadMessageAround,
|
||||
jumpToRecent,
|
||||
} = useThreadMessagesQuery(mainMessage._id);
|
||||
const messages = useMemo(() => data?.messages ?? [], [data?.messages]);
|
||||
|
||||
const userInteractedRef = useRef(false);
|
||||
const isJumpingToMessageRef = useRef(false);
|
||||
const isPrependRef = useRef(false);
|
||||
|
||||
const loadMoreMessages = useDebouncedCallback(
|
||||
() => {
|
||||
if (userInteractedRef.current && !isJumpingToMessageRef.current && hasNextPage && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
100,
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
const initialScrollDoneRef = useRef(false);
|
||||
|
||||
const loadPreviousMessages = useDebouncedCallback(
|
||||
() => {
|
||||
if (
|
||||
userInteractedRef.current &&
|
||||
!isJumpingToMessageRef.current &&
|
||||
initialScrollDoneRef.current &&
|
||||
isAtBottom.current !== true &&
|
||||
hasPreviousPage &&
|
||||
!isFetchingPreviousPage
|
||||
) {
|
||||
isPrependRef.current = true;
|
||||
void fetchPreviousPage();
|
||||
}
|
||||
},
|
||||
100,
|
||||
[hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage],
|
||||
);
|
||||
|
||||
const room = useRoom();
|
||||
const uid = useUserId();
|
||||
|
||||
const hideUsernames = useUserPreference<boolean>('hideUsernames');
|
||||
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
|
||||
const firstUnreadMessageId = useFirstUnreadMessageId();
|
||||
const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300);
|
||||
|
||||
const { messageListRef } = useMessageListNavigation();
|
||||
|
||||
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
|
||||
const isAtBottom = useRef<boolean | null>(null);
|
||||
const prevItemsLengthRef = useRef(0);
|
||||
|
||||
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
|
||||
const messagesLength = messages.length;
|
||||
useEffect(() => {
|
||||
setKeepAtBottom(() => {
|
||||
if (virtualizerRef.current && !msgJumpParam && !isJumpingToMessageRef.current) {
|
||||
virtualizerRef.current.scrollToIndex(messagesLength + 1, {
|
||||
align: 'end',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [messagesLength, setKeepAtBottom, msgJumpParam]);
|
||||
const loadingWindowKeyRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !msgJumpParam || isFetchingNextPage || isFetchingPreviousPage) {
|
||||
return;
|
||||
}
|
||||
if (msgJumpParam === mainMessage._id && !hasPreviousPage) {
|
||||
return;
|
||||
}
|
||||
if (messages.some((message) => message._id === msgJumpParam)) {
|
||||
return;
|
||||
}
|
||||
const windowKey = `${mainMessage._id}:${msgJumpParam}`;
|
||||
if (loadingWindowKeyRef.current === windowKey) {
|
||||
return;
|
||||
}
|
||||
loadingWindowKeyRef.current = windowKey;
|
||||
loadMessageAround(msgJumpParam).catch(() => {
|
||||
if (loadingWindowKeyRef.current === windowKey) {
|
||||
loadingWindowKeyRef.current = undefined;
|
||||
}
|
||||
isJumpingToMessageRef.current = false;
|
||||
});
|
||||
}, [loading, isFetchingNextPage, isFetchingPreviousPage, msgJumpParam, messages, mainMessage._id, loadMessageAround, hasPreviousPage]);
|
||||
|
||||
const interactionRef = useSafeRefCallback(
|
||||
useCallback((element: HTMLDivElement) => {
|
||||
const markInteracted = () => {
|
||||
userInteractedRef.current = true;
|
||||
};
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (['PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
|
||||
userInteractedRef.current = true;
|
||||
}
|
||||
};
|
||||
const parent = element.parentElement;
|
||||
|
||||
parent?.addEventListener('pointerdown', markInteracted);
|
||||
element.addEventListener('wheel', markInteracted, { passive: true });
|
||||
element.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
parent?.removeEventListener('pointerdown', markInteracted);
|
||||
element.removeEventListener('wheel', markInteracted);
|
||||
element.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef, interactionRef);
|
||||
|
||||
const lastScrollSizeRef = useRef(0);
|
||||
|
||||
const showMainMessage = !hasPreviousPage;
|
||||
const items = useMemo(() => {
|
||||
if (loading) {
|
||||
return [];
|
||||
}
|
||||
return showMainMessage ? [mainMessage, ...messages] : messages;
|
||||
}, [loading, showMainMessage, mainMessage, messages]);
|
||||
|
||||
const prevItemsCountRef = useRef(0);
|
||||
useLayoutEffect(() => {
|
||||
if (isPrependRef.current && items.length !== prevItemsCountRef.current) {
|
||||
isPrependRef.current = false;
|
||||
}
|
||||
prevItemsCountRef.current = items.length;
|
||||
if (hasNextPage) {
|
||||
isAtBottom.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
const threadMsgTargetIndex = useMemo(() => {
|
||||
if (!msgJumpParam || loading) {
|
||||
return -1;
|
||||
}
|
||||
if (msgJumpParam === mainMessage._id) {
|
||||
return showMainMessage ? 0 : -1;
|
||||
}
|
||||
const replyIndex = messages.findIndex((m) => m._id === msgJumpParam);
|
||||
if (replyIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
const mainMessageOffset = showMainMessage ? 1 : 0;
|
||||
const previousPageLoaderOffset = hasPreviousPage ? 1 : 0;
|
||||
return mainMessageOffset + previousPageLoaderOffset + replyIndex;
|
||||
}, [msgJumpParam, loading, mainMessage._id, messages, showMainMessage, hasPreviousPage]);
|
||||
|
||||
const lastThreadJumpKeyRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
lastThreadJumpKeyRef.current = undefined;
|
||||
loadingWindowKeyRef.current = undefined;
|
||||
prevItemsLengthRef.current = 0;
|
||||
initialScrollDoneRef.current = false;
|
||||
userInteractedRef.current = false;
|
||||
isJumpingToMessageRef.current = false;
|
||||
isPrependRef.current = false;
|
||||
}, [mainMessage._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!msgJumpParam) {
|
||||
isJumpingToMessageRef.current = false;
|
||||
return;
|
||||
}
|
||||
isJumpingToMessageRef.current = true;
|
||||
isAtBottom.current = false;
|
||||
}, [msgJumpParam]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = virtualizerRef.current;
|
||||
if (!handle) return;
|
||||
if (loading) return;
|
||||
// `msg` deep link: jump effect runs below; do not force scroll to bottom
|
||||
if (msgJumpParam) {
|
||||
setShouldJumpToBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to bottom when current user's optimistic (temp) message is appended.
|
||||
// Fires before server confirmation, giving immediate scroll feedback.
|
||||
const prev = prevItemsLengthRef.current;
|
||||
prevItemsLengthRef.current = items.length;
|
||||
if (items.length > prev && uid) {
|
||||
const lastItem = items.at(-1);
|
||||
if (lastItem?.temp && lastItem.u._id === uid && !hasNextPage) {
|
||||
setShouldJumpToBottom(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAtBottom.current === true && !isFetchingPreviousPage && !isFetchingNextPage && lastScrollSizeRef.current !== handle?.scrollSize) {
|
||||
lastScrollSizeRef.current = handle?.scrollSize ?? 0;
|
||||
setShouldJumpToBottom(true);
|
||||
}
|
||||
if (shouldJumpToBottom) {
|
||||
// Optimistically mark as at-bottom before the scroll executes (rAF).
|
||||
// This ensures the ResizeObserver in useKeepAtBottom re-scrolls if
|
||||
// quote/attachment content grows between now and when Virtua fires the scroll.
|
||||
isAtBottom.current = true;
|
||||
handle.scrollToIndex(items.length, { align: 'end' });
|
||||
setShouldJumpToBottom(false);
|
||||
initialScrollDoneRef.current = true;
|
||||
}
|
||||
}, [
|
||||
items,
|
||||
loading,
|
||||
msgJumpParam,
|
||||
threadMsgTargetIndex,
|
||||
shouldJumpToBottom,
|
||||
setShouldJumpToBottom,
|
||||
uid,
|
||||
isFetchingPreviousPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadMsgTargetIndex < 0 || !msgJumpParam) {
|
||||
return;
|
||||
}
|
||||
const jumpKey = `${mainMessage._id}:${msgJumpParam}`;
|
||||
if (lastThreadJumpKeyRef.current === jumpKey) {
|
||||
return;
|
||||
}
|
||||
const handle = virtualizerRef.current;
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
lastThreadJumpKeyRef.current = jumpKey;
|
||||
setShouldJumpToBottom(false);
|
||||
initialScrollDoneRef.current = true;
|
||||
|
||||
let frame = 0;
|
||||
let rafId = 0;
|
||||
const recenter = () => {
|
||||
handle.scrollToIndex(threadMsgTargetIndex, { align: 'center' });
|
||||
frame += 1;
|
||||
if (frame < 8) {
|
||||
rafId = requestAnimationFrame(recenter);
|
||||
}
|
||||
};
|
||||
recenter();
|
||||
|
||||
setHighlightMessage(msgJumpParam);
|
||||
const highlightTimeout = setTimeout(() => {
|
||||
clearHighlightMessage();
|
||||
}, 2000);
|
||||
const settleTimeout = setTimeout(() => {
|
||||
isJumpingToMessageRef.current = false;
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
clearTimeout(highlightTimeout);
|
||||
clearTimeout(settleTimeout);
|
||||
};
|
||||
}, [threadMsgTargetIndex, msgJumpParam, mainMessage._id, setShouldJumpToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!msgJumpParam) {
|
||||
return;
|
||||
}
|
||||
const clearMsgJumpParam = () => {
|
||||
if (messages.find((m) => m._id === msgJumpParam) && mainMessage._id !== msgJumpParam) {
|
||||
setMessageJumpQueryStringParameter(null);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
clearMsgJumpParam();
|
||||
}, 500);
|
||||
}, [msgJumpParam, messages, mainMessage._id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlerId = `thread-scroll-${mainMessage._id}`;
|
||||
clientCallbacks.add(
|
||||
'streamNewMessage',
|
||||
(msg: IMessage) => {
|
||||
if (msg.rid !== room._id || isEditedMessage(msg) || msg.tmid !== mainMessage._id) {
|
||||
return;
|
||||
}
|
||||
if (msg.u._id === uid) {
|
||||
if (hasNextPage) {
|
||||
void jumpToRecent().then(() => setShouldJumpToBottom(true));
|
||||
return;
|
||||
}
|
||||
setShouldJumpToBottom(true);
|
||||
}
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
handlerId,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('streamNewMessage', handlerId);
|
||||
};
|
||||
}, [room._id, uid, mainMessage._id, setShouldJumpToBottom, hasNextPage, jumpToRecent]);
|
||||
|
||||
const keepMountedMessages = useKeepMountedMessages(items);
|
||||
|
||||
return (
|
||||
<div className={['thread-list js-scroll-thread', hideUsernames && 'hide-usernames'].filter(isTruthy).join(' ')}>
|
||||
<BubbleDate ref={bubbleRef} {...bubbleDate} />
|
||||
<CustomVirtuaScrollbars ref={mergedRefs}>
|
||||
<MessageListProvider>
|
||||
<VList
|
||||
ref={virtualizerRef}
|
||||
shift={isPrependRef.current}
|
||||
style={{ height: '100%' }}
|
||||
aria-label={t('Thread_message_list')}
|
||||
aria-busy={loading || isFetchingNextPage || isFetchingPreviousPage}
|
||||
role='list'
|
||||
keepMounted={keepMountedMessages}
|
||||
onScroll={(offset) => {
|
||||
const handle = virtualizerRef.current;
|
||||
if (!handle) return;
|
||||
|
||||
if (offset < 200 && hasPreviousPage && !isJumpingToMessageRef.current) {
|
||||
loadPreviousMessages();
|
||||
}
|
||||
|
||||
// Copied from messageList, I'm unsure why this is necessary, but it seems to be needed to properly set the isAtBottom state
|
||||
if (hasNextPage) {
|
||||
isAtBottom.current = false;
|
||||
} else {
|
||||
if (handle.scrollSize >= handle.viewportSize) {
|
||||
isAtBottom.current = true;
|
||||
}
|
||||
isAtBottom.current = offset - handle.scrollSize + handle.viewportSize >= -20;
|
||||
}
|
||||
|
||||
if (hasNextPage && !isJumpingToMessageRef.current && offset - handle.scrollSize + handle.viewportSize >= -200) {
|
||||
loadMoreMessages();
|
||||
}
|
||||
|
||||
const topMessage = items[handle.findItemIndex(handle.scrollOffset)];
|
||||
handleDateScroll(topMessage, offset);
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className='load-more' role='presentation'>
|
||||
<LoadingMessagesIndicator />
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && hasPreviousPage ? (
|
||||
<div className='load-more' role='presentation'>
|
||||
{isFetchingPreviousPage ? <LoadingMessagesIndicator /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading &&
|
||||
items.map((message, index, { [index - 1]: previous }) => {
|
||||
const sequential = isMessageSequential(message, previous, messageGroupingPeriod);
|
||||
const newDay = isMessageNewDay(message, previous);
|
||||
const shouldShowAsSequential = sequential && !newDay;
|
||||
const firstUnread = firstUnreadMessageId === message._id;
|
||||
const system = MessageTypes.isSystemMessage(message);
|
||||
|
||||
return (
|
||||
<Fragment key={message._id}>
|
||||
<ThreadMessageItem
|
||||
message={message}
|
||||
previous={previous}
|
||||
sequential={sequential}
|
||||
shouldShowAsSequential={shouldShowAsSequential}
|
||||
showUserAvatar={showUserAvatar}
|
||||
firstUnread={firstUnread}
|
||||
system={system}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!loading && hasNextPage ? (
|
||||
<div className='load-more' role='presentation'>
|
||||
{isFetchingNextPage ? <LoadingMessagesIndicator /> : <InfiniteListAnchor loadMore={loadMoreMessages} />}
|
||||
</div>
|
||||
) : null}
|
||||
</VList>
|
||||
</MessageListProvider>
|
||||
</CustomVirtuaScrollbars>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadMessageList;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
|
||||
const ThreadSkeleton = () => {
|
||||
return (
|
||||
<Box padding={24}>
|
||||
<Skeleton width='32px' height='32px' variant='rect' /> <Skeleton />
|
||||
{Array(5)
|
||||
.fill(5)
|
||||
.map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadSkeleton;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { ContextualbarTitle } from '@rocket.chat/ui-client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useNormalizedThreadTitleHtml } from '../hooks/useNormalizedThreadTitleHtml';
|
||||
|
||||
export type ThreadTitleProps = {
|
||||
mainMessage: IThreadMainMessage;
|
||||
};
|
||||
|
||||
const ThreadTitle = ({ mainMessage }: ThreadTitleProps) => {
|
||||
const html = useNormalizedThreadTitleHtml(mainMessage);
|
||||
|
||||
const innerHTML = useMemo(() => ({ __html: html }), [html]);
|
||||
return <ContextualbarTitle dangerouslySetInnerHTML={innerHTML} />;
|
||||
};
|
||||
|
||||
export default ThreadTitle;
|
||||
@@ -0,0 +1,6 @@
|
||||
.thread-list {
|
||||
overflow: hidden auto;
|
||||
word-wrap: break-word;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
|
||||
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
|
||||
import { Messages } from '../../../../../stores';
|
||||
|
||||
export const useGetMessageByID = (shouldStoreMessage: boolean = true) => {
|
||||
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
|
||||
const storeMessage = Messages.use((state) => state.store);
|
||||
|
||||
return useCallback(
|
||||
async (mid: IMessage['_id']) => {
|
||||
try {
|
||||
const { message: rawMessage } = await getMessage({ msgId: mid });
|
||||
const mappedMessage = mapMessageFromApi(rawMessage);
|
||||
const message = (await onClientMessageReceived(mappedMessage)) || mappedMessage;
|
||||
if (shouldStoreMessage) {
|
||||
storeMessage(message);
|
||||
}
|
||||
return message;
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'success' in error) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getMessage, shouldStoreMessage, storeMessage],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useNormalizedThreadTitleHtml } from './useNormalizedThreadTitleHtml';
|
||||
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';
|
||||
|
||||
jest.mock('../../../../../../app/markdown/lib/markdown', () => ({
|
||||
filterMarkdown: jest.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../lib/emoji/emojiParser', () => ({
|
||||
emojiParser: jest.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
const mockedFilterMarkdown = jest.mocked(filterMarkdown);
|
||||
|
||||
const baseMessage = {
|
||||
_id: 'msg-id',
|
||||
rid: 'rid',
|
||||
ts: new Date(),
|
||||
u: { _id: 'uid', username: 'user' },
|
||||
_updatedAt: new Date(),
|
||||
tcount: 1,
|
||||
tlm: new Date(),
|
||||
replies: [],
|
||||
} as unknown as IThreadMainMessage;
|
||||
|
||||
describe('useNormalizedThreadTitleHtml', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the "Message_removed" translation for a removed message without content', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
t: 'rm',
|
||||
msg: '',
|
||||
editedAt: new Date(),
|
||||
editedBy: { _id: 'uid', username: 'user' },
|
||||
} as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('Message_removed');
|
||||
expect(mockedFilterMarkdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the normalized message content for a regular message', () => {
|
||||
const message = { ...baseMessage, msg: 'Hello world' } as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('Hello world');
|
||||
expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world');
|
||||
});
|
||||
|
||||
it('should render the message content when a `t: rm` message still has content', () => {
|
||||
const message = { ...baseMessage, t: 'rm', msg: 'Hello world' } as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe('Message_removed');
|
||||
expect(result.current).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import { useUser, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';
|
||||
import { MentionsParser } from '../../../../../../app/mentions/lib/MentionsParser';
|
||||
import { emojiParser } from '../../../../../lib/emoji/emojiParser';
|
||||
|
||||
export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) => {
|
||||
const { t } = useTranslation();
|
||||
const me = useUser()?.username || '';
|
||||
const pattern = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
|
||||
const useRealName = useSetting('UI_Use_Real_Name', false);
|
||||
|
||||
return useMemo((): string => {
|
||||
const message = { ...mainMessage };
|
||||
const messageType = MessageTypes.getType(message);
|
||||
|
||||
if (message.msg) {
|
||||
const filteredMessage = filterMarkdown(escapeHTML(message.msg));
|
||||
if (!message.channels && !message.mentions) {
|
||||
return filteredMessage;
|
||||
}
|
||||
|
||||
const instance = new MentionsParser({
|
||||
pattern: () => pattern,
|
||||
useRealName: () => useRealName,
|
||||
me: () => me,
|
||||
userTemplate: ({ label }) => ` ${label} `,
|
||||
roomTemplate: ({ prefix, mention }) => `${prefix} ${mention} `,
|
||||
});
|
||||
const html = emojiParser(filteredMessage);
|
||||
return instance.parse({ ...message, msg: filteredMessage, html }).html ?? '';
|
||||
}
|
||||
|
||||
if (message.attachments) {
|
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
|
||||
|
||||
if (attachment?.description) {
|
||||
return escapeHTML(attachment.description);
|
||||
}
|
||||
|
||||
if (attachment?.title) {
|
||||
return escapeHTML(attachment.title);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.t && messageType) {
|
||||
return escapeHTML(messageType.text(t, message, { capitalize: true }));
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [mainMessage, me, pattern, useRealName, t]);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { IMessage, IThreadMainMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
import { useStream } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useGetMessageByID } from './useGetMessageByID';
|
||||
import { withDebouncing } from '../../../../../../lib/utils/highOrderFunctions';
|
||||
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete';
|
||||
import { createDeleteCriteria } from '../../../../../lib/utils/threadMessageUtils';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
type RoomMessagesRidEvent = IMessage;
|
||||
|
||||
const useSubscribeToMessage = () => {
|
||||
const subscribeToRoomMessages = useStream('room-messages');
|
||||
const subscribeToNotifyRoom = useStream('notify-room');
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
message: IMessage,
|
||||
{
|
||||
onMutate,
|
||||
onDelete,
|
||||
onFilesDelete,
|
||||
onMessagesRead,
|
||||
}: {
|
||||
onMutate?: (message: IMessage) => void | Promise<void>;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
onFilesDelete?: (replaceFileAttachmentsWith?: MessageAttachment) => void | Promise<void>;
|
||||
onMessagesRead?: (event: { tmid?: string; until: Date }) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const unsubscribeFromRoomMessages = subscribeToRoomMessages(message.rid, (event: RoomMessagesRidEvent) => {
|
||||
if (message._id === event._id) onMutate?.(event);
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${message.rid}/deleteMessage`, (event) => {
|
||||
if (message._id === event._id) onDelete?.();
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${message.rid}/deleteMessageBulk`, (params) => {
|
||||
const matchDeleteCriteria = createDeleteCriteria(params);
|
||||
if (matchDeleteCriteria(message)) {
|
||||
if (params.filesOnly) {
|
||||
return onFilesDelete?.(params.replaceFileAttachmentsWith);
|
||||
}
|
||||
return onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMessagesRead = subscribeToNotifyRoom(`${message.rid}/messagesRead`, (event) => {
|
||||
onMessagesRead?.(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromRoomMessages();
|
||||
unsubscribeFromDeleteMessage();
|
||||
unsubscribeFromDeleteMessageBulk();
|
||||
unsubscribeFromMessagesRead();
|
||||
};
|
||||
},
|
||||
[subscribeToNotifyRoom, subscribeToRoomMessages],
|
||||
);
|
||||
};
|
||||
|
||||
export const useThreadMainMessageQuery = (
|
||||
tmid: IMessage['_id'],
|
||||
{ onDelete }: { onDelete?: () => void } = {},
|
||||
): UseQueryResult<IThreadMainMessage, Error> => {
|
||||
const room = useRoom();
|
||||
|
||||
const getMessage = useGetMessageByID(false);
|
||||
const subscribeToMessage = useSubscribeToMessage();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const unsubscribeRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = undefined;
|
||||
};
|
||||
}, [tmid]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: roomsQueryKeys.threadMainMessage(room._id, tmid),
|
||||
|
||||
queryFn: async ({ queryKey }) => {
|
||||
const mainMessage = await getMessage(tmid);
|
||||
|
||||
if (!mainMessage) {
|
||||
throw new Error('Invalid main message');
|
||||
}
|
||||
|
||||
const debouncedInvalidate = withDebouncing({ wait: 10000 })(() => {
|
||||
queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
});
|
||||
|
||||
unsubscribeRef.current =
|
||||
unsubscribeRef.current ||
|
||||
subscribeToMessage(mainMessage, {
|
||||
onMutate: async (message) => {
|
||||
const msg = await onClientMessageReceived(message);
|
||||
queryClient.setQueryData(queryKey, () => msg);
|
||||
debouncedInvalidate();
|
||||
},
|
||||
onDelete: () => {
|
||||
onDelete?.();
|
||||
queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
},
|
||||
onFilesDelete: async (replaceFileAttachmentsWith?: MessageAttachment) => {
|
||||
const current = queryClient.getQueryData<IThreadMainMessage>(queryKey);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const updated = modifyMessageOnFilesDelete(current, replaceFileAttachmentsWith);
|
||||
|
||||
const msg = await onClientMessageReceived(updated);
|
||||
queryClient.setQueryData(queryKey, () => msg);
|
||||
debouncedInvalidate();
|
||||
},
|
||||
onMessagesRead: ({ tmid: eventTmid, until }) => {
|
||||
if (eventTmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<IThreadMainMessage>(queryKey, (old) => {
|
||||
if (!old?.unread) {
|
||||
return old;
|
||||
}
|
||||
|
||||
if (new Date(old.ts).getTime() <= new Date(until).getTime()) {
|
||||
const { unread: _, ...rest } = old;
|
||||
return rest as IThreadMainMessage;
|
||||
}
|
||||
|
||||
return old;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return mainMessage;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useThreadMessagesQuery } from './useThreadMessagesQuery';
|
||||
import { createFakeRoom } from '../../../../../../tests/mocks/data';
|
||||
|
||||
const room = createFakeRoom({ _id: 'room-id', t: 'c' });
|
||||
|
||||
jest.mock('../../../contexts/RoomContext', () => ({
|
||||
useRoom: () => room,
|
||||
}));
|
||||
|
||||
const TMID = 'thread-id';
|
||||
const TOTAL = 120;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const createSerializedReply = (index: number): Serialized<IMessage> => {
|
||||
const ts = new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString();
|
||||
|
||||
return {
|
||||
_id: `reply-${index}`,
|
||||
rid: room._id,
|
||||
tmid: TMID,
|
||||
msg: `reply ${index}`,
|
||||
ts,
|
||||
_updatedAt: ts,
|
||||
u: { _id: 'user-id', username: 'user', name: 'User' },
|
||||
} as unknown as Serialized<IMessage>;
|
||||
};
|
||||
|
||||
const allReplies = Array.from({ length: TOTAL }, (_, index) => createSerializedReply(index));
|
||||
|
||||
const idsOf = (messages: { _id: string }[]) => messages.map(({ _id }) => _id);
|
||||
|
||||
const newestPageIds = idsOf(allReplies.slice(TOTAL - PAGE_SIZE));
|
||||
|
||||
type GetThreadMessagesParams = { tmid: string; offset?: number; count?: number; aroundId?: string; sort?: string };
|
||||
|
||||
const setup = ({ serverLimit = Infinity }: { serverLimit?: number } = {}) => {
|
||||
const getThreadMessages = jest.fn(({ offset = 0, count = PAGE_SIZE, aroundId }: GetThreadMessagesParams) => {
|
||||
const effectiveCount = Math.min(count, serverLimit);
|
||||
|
||||
if (aroundId) {
|
||||
const index = allReplies.findIndex(({ _id }) => _id === aroundId);
|
||||
const start = Math.max(0, index - Math.floor(effectiveCount / 2));
|
||||
|
||||
return { messages: allReplies.slice(start, start + effectiveCount), count: effectiveCount, offset: start, total: TOTAL };
|
||||
}
|
||||
|
||||
const end = TOTAL - offset;
|
||||
const start = Math.max(0, end - effectiveCount);
|
||||
|
||||
return { messages: allReplies.slice(start, end), count: effectiveCount, offset, total: TOTAL };
|
||||
});
|
||||
|
||||
const wrapper = mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withEndpoint('GET', '/v1/chat.getThreadMessages', getThreadMessages)
|
||||
.withEndpoint('POST', '/v1/chat.readThread', () => null)
|
||||
.build();
|
||||
|
||||
const { result } = renderHook(() => useThreadMessagesQuery(TMID), { wrapper });
|
||||
|
||||
return { result, getThreadMessages };
|
||||
};
|
||||
|
||||
const waitForLoaded = async (result: { current: ReturnType<typeof useThreadMessagesQuery> }) =>
|
||||
waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
describe('useThreadMessagesQuery', () => {
|
||||
it('reports no unloaded newer messages after the initial load', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
expect(result.current.hasPreviousPage).toBe(true);
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds);
|
||||
});
|
||||
|
||||
it('reports unloaded newer messages after jumping to an old reply', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(idsOf(allReplies.slice(0, PAGE_SIZE)));
|
||||
});
|
||||
|
||||
describe('jumpToRecent', () => {
|
||||
it('refetches from the newest page and clears the unloaded-newer-messages flag', async () => {
|
||||
const { result, getThreadMessages } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
getThreadMessages.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.jumpToRecent();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||
expect(getThreadMessages).toHaveBeenCalledWith(expect.objectContaining({ tmid: TMID, offset: 0 }));
|
||||
});
|
||||
|
||||
it('discards the detached window instead of merging it into the newest page', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.jumpToRecent();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the server clamps the response size (e.g. "Max Record Amount")', () => {
|
||||
const SERVER_LIMIT = 20;
|
||||
const AROUND_INDEX = 60;
|
||||
|
||||
it('does not skip a batch of messages when loading the next page after jumping to an old reply', async () => {
|
||||
const { result } = setup({ serverLimit: SERVER_LIMIT });
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround(`reply-${AROUND_INDEX}`);
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
const idsBeforeNextPage = idsOf(result.current.data?.messages ?? []);
|
||||
const oldestLoadedIndex = allReplies.findIndex(({ _id }) => _id === idsBeforeNextPage[0]);
|
||||
const newestLoadedIndex = allReplies.findIndex(({ _id }) => _id === idsBeforeNextPage[idsBeforeNextPage.length - 1]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
|
||||
// The newly-loaded batch must continue immediately after the previously loaded one, with no gap.
|
||||
const idsAfterNextPage = idsOf(result.current.data?.messages ?? []);
|
||||
expect(idsAfterNextPage).toEqual(idsOf(allReplies.slice(oldestLoadedIndex, newestLoadedIndex + 1 + SERVER_LIMIT)));
|
||||
});
|
||||
|
||||
it('reaches the newest message without gaps after repeatedly loading next pages', async () => {
|
||||
const { result } = setup({ serverLimit: SERVER_LIMIT });
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround(`reply-${AROUND_INDEX}`);
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
const oldestLoadedIndex = allReplies.findIndex(({ _id }) => _id === (result.current.data?.messages ?? [])[0]._id);
|
||||
|
||||
while (result.current.hasNextPage) {
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
}
|
||||
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(idsOf(allReplies.slice(oldestLoadedIndex)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { IRoom, IMessage, IThreadMainMessage, IThreadMessage, Serialized } from '@rocket.chat/core-typings';
|
||||
import { isThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useStream } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { getNumericConfig } from '../../../../../lib/utils/getConfig';
|
||||
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
|
||||
import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete';
|
||||
import {
|
||||
createDeleteCriteria,
|
||||
markThreadMessagesAsRead,
|
||||
mergeThreadMessages,
|
||||
mutateThreadMessagesInfiniteData,
|
||||
type ThreadMessagesInfiniteData,
|
||||
upsertThreadMessageInCache,
|
||||
} from '../../../../../lib/utils/threadMessageUtils';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
const processMessages = async (messages: IMessage[]): Promise<IMessage[]> => {
|
||||
return Promise.all(messages.map((msg) => onClientMessageReceived(msg)));
|
||||
};
|
||||
|
||||
const filterThreadMessages = (messages: Serialized<IMessage>[], tmid: IThreadMainMessage['_id']): IThreadMessage[] => {
|
||||
return messages
|
||||
.map((m) => mapMessageFromApi(m))
|
||||
.filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true);
|
||||
};
|
||||
|
||||
export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IRoom['_id']) => {
|
||||
const room = useRoom();
|
||||
const roomId = rid ?? room._id;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages');
|
||||
// `chat.getThreadMessages` is a plain read, so marking the thread as read is an
|
||||
// explicit call — the DDP method it replaced did both server-side.
|
||||
const readThread = useEndpoint('POST', '/v1/chat.readThread');
|
||||
|
||||
const subscribeToRoomMessages = useStream('room-messages');
|
||||
const subscribeToNotifyRoom = useStream('notify-room');
|
||||
|
||||
const unprocessedReadMessagesEvent = useRef<{ tmid: string; until: Date } | null>(null);
|
||||
|
||||
const count = getNumericConfig('threadMessagesSize', 50);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
|
||||
const unsubscribeFromRoomMessages = subscribeToRoomMessages(roomId, async (event) => {
|
||||
if (event.tmid !== tmid || event._hidden === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processed = await onClientMessageReceived(event);
|
||||
upsertThreadMessageInCache(processed, roomId, tmid, queryClient);
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, (event) => {
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
const index = messages.findIndex((m) => m._id === event._id);
|
||||
if (index !== -1) {
|
||||
messages.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, (bulkParams) => {
|
||||
const matchDeleteCriteria = createDeleteCriteria(bulkParams);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
if (bulkParams.filesOnly) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const msg = messages[index];
|
||||
if (matchDeleteCriteria(msg)) {
|
||||
messages[index] = modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = messages.filter((msg) => !matchDeleteCriteria(msg));
|
||||
messages.splice(0, messages.length, ...filtered);
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeFromMessagesRead = subscribeToNotifyRoom(`${roomId}/messagesRead`, ({ tmid: eventTmid, until }) => {
|
||||
if (eventTmid && eventTmid !== tmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPending = queryClient.getQueryState(currentQueryKey)?.fetchStatus === 'fetching';
|
||||
if (isPending) {
|
||||
unprocessedReadMessagesEvent.current = { tmid, until };
|
||||
return;
|
||||
}
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
const updated = markThreadMessagesAsRead(messages, until);
|
||||
messages.splice(0, messages.length, ...updated);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromRoomMessages();
|
||||
unsubscribeFromDeleteMessage();
|
||||
unsubscribeFromDeleteMessageBulk();
|
||||
unsubscribeFromMessagesRead();
|
||||
};
|
||||
}, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]);
|
||||
|
||||
const loadMessageAround = useCallback(
|
||||
async (messageId: string) => {
|
||||
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: currentQueryKey });
|
||||
|
||||
const { messages, total, offset } = await getThreadMessages({
|
||||
tmid,
|
||||
aroundId: messageId,
|
||||
count,
|
||||
sort: JSON.stringify({ ts: 1 }),
|
||||
});
|
||||
|
||||
const filtered = filterThreadMessages(messages, tmid);
|
||||
const processed = (await processMessages(filtered)) as IThreadMessage[];
|
||||
|
||||
const pageSize = Math.min(processed.length, count);
|
||||
const pageParam = Math.max(0, total - offset - pageSize);
|
||||
|
||||
queryClient.setQueryData<ThreadMessagesInfiniteData>(currentQueryKey, {
|
||||
pages: [{ items: processed, itemCount: total }],
|
||||
pageParams: [pageParam],
|
||||
});
|
||||
},
|
||||
[queryClient, getThreadMessages, roomId, tmid, count],
|
||||
);
|
||||
|
||||
const jumpToRecent = useCallback(async () => {
|
||||
await queryClient.resetQueries({ queryKey: roomsQueryKeys.threadMessages(roomId, tmid) });
|
||||
}, [queryClient, roomId, tmid]);
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
if (offset === 0) {
|
||||
void Promise.resolve(readThread({ tmid })).catch(() => undefined);
|
||||
}
|
||||
|
||||
const cachedData = offset === 0 ? queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey) : undefined;
|
||||
const cachedMessages = cachedData?.pages.at(-1)?.items ?? [];
|
||||
|
||||
const { messages, total } = await getThreadMessages({
|
||||
tmid,
|
||||
offset,
|
||||
count,
|
||||
sort: JSON.stringify({ ts: -1 }),
|
||||
});
|
||||
|
||||
let filtered = filterThreadMessages(messages, tmid);
|
||||
if (offset === 0) {
|
||||
filtered = mergeThreadMessages(cachedMessages, filtered);
|
||||
}
|
||||
|
||||
if (offset === 0 && unprocessedReadMessagesEvent.current?.tmid === tmid) {
|
||||
const { until } = unprocessedReadMessagesEvent.current;
|
||||
unprocessedReadMessagesEvent.current = null;
|
||||
filtered = markThreadMessagesAsRead(filtered, until);
|
||||
}
|
||||
|
||||
const processed = (await processMessages(filtered)) as IThreadMessage[];
|
||||
|
||||
return {
|
||||
items: processed,
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||
if (lastPageParam <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const pageSize = Math.min(lastPage.items.length, count);
|
||||
if (pageSize <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, lastPageParam - pageSize);
|
||||
},
|
||||
getPreviousPageParam: (firstPage, _allPages, firstPageParam) => {
|
||||
const pageSize = Math.min(firstPage.items.length, count);
|
||||
if (pageSize <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const next = firstPageParam + pageSize;
|
||||
return next < firstPage.itemCount ? next : undefined;
|
||||
},
|
||||
select: ({ pages }) => {
|
||||
const byId = new Map<string, IThreadMessage>();
|
||||
for (const page of pages) {
|
||||
for (const item of page.items) {
|
||||
byId.set(item._id, item);
|
||||
}
|
||||
}
|
||||
const messages = Array.from(byId.values()).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
return {
|
||||
messages,
|
||||
itemCount: pages.at(-1)?.itemCount ?? 0,
|
||||
};
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return { ...query, loadMessageAround, jumpToRecent };
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IThreadMainMessage, IMessage, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useInfiniteMessageQueryUpdates } from '../../../../../hooks/useInfiniteMessageQueryUpdates';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { getNumericConfig } from '../../../../../lib/utils/getConfig';
|
||||
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
|
||||
|
||||
type ThreadsListOptions =
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type: 'unread';
|
||||
tunread: ISubscription['tunread'];
|
||||
}
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type: 'following';
|
||||
tunread?: never;
|
||||
}
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type?: undefined;
|
||||
tunread?: never;
|
||||
};
|
||||
|
||||
export const useThreadsList = ({ rid, text, type, tunread }: ThreadsListOptions) => {
|
||||
const getThreadsList = useEndpoint('GET', '/v1/chat.getThreadsList');
|
||||
|
||||
const count = getNumericConfig('threadsListSize', 10);
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
useInfiniteMessageQueryUpdates({
|
||||
queryKey: roomsQueryKeys.threads(rid, { type, text }),
|
||||
roomId: rid,
|
||||
// Replicates the filtering done server-side
|
||||
filter: (message): message is IThreadMainMessage => {
|
||||
if (typeof message.tcount !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'following') {
|
||||
if (!userId || !message.replies?.includes(userId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'unread') {
|
||||
if (!tunread?.includes(message._id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const regex = new RegExp(escapeRegExp(text), 'i');
|
||||
if (!regex.test(message.msg)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
// Replicates the sorting done server-side
|
||||
compare: (a, b) => (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(),
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: roomsQueryKeys.threads(rid, { type, text }),
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
const { threads, total } = await getThreadsList({
|
||||
rid,
|
||||
type,
|
||||
text,
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
return {
|
||||
items: threads.map(mapMessageFromApi) as IThreadMainMessage[],
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// FIXME: This is an estimation, as threads can be created or removed while paginating
|
||||
// Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt"
|
||||
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
|
||||
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
|
||||
},
|
||||
select: ({ pages }) => ({
|
||||
items: pages.flatMap((page) => page.items),
|
||||
itemCount: pages.at(-1)?.itemCount ?? 0,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
|
||||
// TODO: its core should be moved to the ChatContext
|
||||
|
||||
type UseToggleFollowingThreadMutationVariables = {
|
||||
rid: IMessage['rid'];
|
||||
tmid: IMessage['_id'];
|
||||
follow: boolean;
|
||||
};
|
||||
|
||||
export const useToggleFollowingThreadMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, UseToggleFollowingThreadMutationVariables>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, UseToggleFollowingThreadMutationVariables> => {
|
||||
const followMessage = useEndpoint('POST', '/v1/chat.followMessage');
|
||||
const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tmid, follow }) => {
|
||||
if (follow) {
|
||||
await followMessage({ mid: tmid });
|
||||
return;
|
||||
}
|
||||
|
||||
await unfollowMessage({ mid: tmid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, variables, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.threads(variables.rid) });
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.message(variables.rid, variables.tmid) });
|
||||
return options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Threads';
|
||||
Reference in New Issue
Block a user