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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { DragEvent, ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
export type DropTargetOverlayProps = {
enabled: boolean;
reason?: ReactNode;
onFileDrop?: (files: File[]) => void;
visible?: boolean;
onDismiss?: () => void;
};
function DropTargetOverlay({ enabled, reason, onFileDrop, visible = true, onDismiss }: DropTargetOverlayProps) {
const { t } = useTranslation();
const handleDragLeave = useStableCallback((event: DragEvent) => {
event.stopPropagation();
onDismiss?.();
});
const handleDragOver = useStableCallback((event: DragEvent) => {
event.stopPropagation();
event.preventDefault();
event.dataTransfer.dropEffect = ['move', 'linkMove'].includes(event.dataTransfer.effectAllowed) ? 'move' : 'copy';
});
const formatDateAndTime = useFormatDateAndTime();
const handleDrop = useStableCallback(async (event: DragEvent) => {
event.stopPropagation();
onDismiss?.();
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (event.dataTransfer.types.includes('text/uri-list') && event.dataTransfer.types.includes('text/html')) {
const fragment = document.createRange().createContextualFragment(event.dataTransfer.getData('text/html'));
for await (const { src } of Array.from(fragment.querySelectorAll('img'))) {
try {
const response = await fetch(src);
const data = await response.blob();
const extension = (await import('../../../../app/utils/lib/mimeTypes')).mime.extension(data.type);
const filename = `File - ${formatDateAndTime(new Date())}.${extension}`;
const file = new File([data], filename, { type: data.type });
files.push(file);
} catch (error) {
console.warn(error);
}
}
}
onFileDrop?.(files);
});
if (!visible) {
return null;
}
return (
<Box
role='dialog'
data-qa='DropTargetOverlay'
position='absolute'
zIndex={1_000_000}
inset={0}
display='flex'
alignItems='center'
justifyContent='center'
fontScale='hero'
textAlign='center'
backgroundColor='surface-overlay'
borderWidth={4}
borderStyle='dashed'
borderColor='currentColor'
color={enabled ? 'default' : 'danger'}
className={css`
animation-name: zoom-in;
animation-duration: 0.1s;
`}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{enabled ? t('Drop_to_upload_file') : reason}
</Box>
);
}
export default memo(DropTargetOverlay);

View File

@@ -0,0 +1,59 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Bubble } from '@rocket.chat/fuselage';
import { isTruthy } from '@rocket.chat/tools';
import { useState } from 'react';
export type JumpToRecentMessageButtonProps = {
visible: boolean;
onClick: () => void;
text: string;
};
const buttonStyle = css`
position: absolute;
z-index: 2;
bottom: 8px;
left: 50%;
user-select: none;
transform: translate(-50%, 0);
&.not {
visibility: hidden;
}
&.clicked {
animation: fadeout 1s linear forwards;
}
@keyframes fadeout {
50% {
visibility: visible;
transform: translate(-50%, 150%);
}
100% {
visibility: hidden;
transform: translate(-50%, 150%);
position: fixed;
}
}
`;
const JumpToRecentMessageButton = ({ visible, onClick, text }: JumpToRecentMessageButtonProps) => {
const [clicked, setClicked] = useState(false);
return (
<Box className={[buttonStyle, !visible && 'not', clicked && 'clicked'].filter(isTruthy)}>
<Bubble
icon='arrow-down'
onClick={() => {
onClick();
setClicked(true);
}}
>
{text}
</Bubble>
</Box>
);
};
export default JumpToRecentMessageButton;

View File

@@ -0,0 +1,5 @@
import LoadingIndicator from '../../../components/LoadingIndicator';
const LoadingMessagesIndicator = () => <LoadingIndicator />;
export default LoadingMessagesIndicator;

View File

@@ -0,0 +1,40 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import type { PeerInfo } from '@rocket.chat/ui-voip';
import { MediaCallRoomActivity, usePeekMediaSessionState, usePeekMediaSessionPeerInfo } from '@rocket.chat/ui-voip';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useRoom } from '../contexts/RoomContext';
const isMediaCallRoom = (room: IRoom, peerInfo?: PeerInfo) => {
if (!peerInfo || 'number' in peerInfo) {
return false;
}
if (!isDirectMessageRoom(room)) {
return false;
}
if (room.uids?.length !== 2) {
return false;
}
return room.uids.includes(peerInfo.userId);
};
export type MediaCallRoomProps = {
children: ReactNode;
};
const MediaCallRoom = ({ children }: MediaCallRoomProps) => {
const state = usePeekMediaSessionState();
const peerInfo = usePeekMediaSessionPeerInfo();
const room = useRoom();
if (state !== 'ongoing' || !isMediaCallRoom(room, peerInfo)) {
return children;
}
return <MediaCallRoomActivity>{children}</MediaCallRoomActivity>;
};
export default memo(MediaCallRoom);

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import RetentionPolicyWarning from './RetentionPolicyWarning';
import { createRenteionPolicySettingsMock as createMock } from '../../../../tests/mocks/client/mockRetentionPolicySettings';
import { createFakeRoom } from '../../../../tests/mocks/data';
jest.useFakeTimers();
beforeEach(() => {
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
});
describe('RetentionPolicyWarning', () => {
it('Should render callout if settings are valid', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
render(<RetentionPolicyWarning room={fakeRoom} />, {
wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000 }),
});
expect(screen.getByRole('alert')).toHaveTextContent('1 minute June 1, 2024 at 12:30 AM');
});
it('Should not render callout if settings are invalid', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
render(<RetentionPolicyWarning room={fakeRoom} />, {
wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, advancedPrecisionCron: '* * * 12 *', advancedPrecision: true }),
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Bubble } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { withErrorBoundary } from '../../../components/withErrorBoundary';
import { usePruneWarningMessage } from '../../../hooks/usePruneWarningMessage';
export type RetentionPolicyWarningProps = { room: IRoom };
const RetentionPolicyWarning = ({ room }: RetentionPolicyWarningProps) => {
const { t } = useTranslation();
const message = usePruneWarningMessage(room);
return (
<Box display='flex' justifyContent='center' paddingInline={20} marginBlock={8}>
<Bubble role='alert' aria-live='polite' aria-label={t('Retention_policy_warning_banner')} small secondary>
{message}
</Bubble>
</Box>
);
};
export default withErrorBoundary(RetentionPolicyWarning);

View File

@@ -0,0 +1,254 @@
import { Box } from '@rocket.chat/fuselage';
import { isTruthy } from '@rocket.chat/tools';
import { CustomVirtuaScrollbars, useEmbeddedLayout } from '@rocket.chat/ui-client';
import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useMergedRefsV2 } from '../../../hooks/useMergedRefsV2';
import { BubbleDate } from '../BubbleDate';
import { MessageList } from '../MessageList';
import DropTargetOverlay from './DropTargetOverlay';
import JumpToRecentMessageButton from './JumpToRecentMessageButton';
import UnreadMessagesIndicator from './UnreadMessagesIndicator';
import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary';
import RoomAnnouncement from '../RoomAnnouncement';
import UploadProgressIndicator from './UploadProgress';
import ComposerContainer from '../composer/ComposerContainer';
import { useFileUpload } from './hooks/useFileUpload';
import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved';
import { useIsAtBottomRef } from './hooks/useIsAtBottomRef';
import { useQuoteMessageByUrl } from './hooks/useQuoteMessageByUrl';
import { useReadMessageWindowEvents } from './hooks/useReadMessageWindowEvents';
import RoomComposer from '../composer/RoomComposer/RoomComposer';
import { useChat } from '../contexts/ChatContext';
import { useRoom, useRoomSubscription, useRoomMessages } from '../contexts/RoomContext';
import { useDateScroll } from '../hooks/useDateScroll';
import { useMessageListNavigation } from '../hooks/useMessageListNavigation';
import { useRetentionPolicy } from '../hooks/useRetentionPolicy';
import { useFileUploadDropTarget } from './hooks/useFileUploadDropTarget';
import { useGetMore } from './hooks/useGetMore';
import { useHasNewMessages } from './hooks/useHasNewMessages';
import { useSelectAllAndScrollToTop } from './hooks/useSelectAllAndScrollToTop';
import { useHandleUnread } from './hooks/useUnreadMessages';
import { useKeepAtBottom } from '../MessageList/hooks/useKeepAtBottom';
import useTryToJumpToThreadMessage from '../MessageList/hooks/useTryToJumpToThreadMessage';
const RoomBody = () => {
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
const t = useTranslation();
const isLayoutEmbedded = useEmbeddedLayout();
const room = useRoom();
const user = useUser();
const toolbox = useRoomToolbox();
const admin = useRole('admin');
const subscription = useRoomSubscription();
const [shouldJumpToBottom, setShouldJumpToBottom] = useState<boolean>(false);
const isAtBottom = useIsAtBottomRef(room._id);
const [isJumpingToMessage, setIsJumpingToMessage] = useState<boolean>(false);
const retentionPolicy = useRetentionPolicy(room);
useTryToJumpToThreadMessage();
const hideFlexTab = useUserPreference<boolean>('hideFlexTab') || undefined;
const hideUsernames = useUserPreference<boolean>('hideUsernames');
const displayAvatars = useUserPreference<boolean>('displayAvatars');
const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useRoomMessages();
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', false);
const canPreviewChannelRoom = usePermission('preview-c-room');
const subscribed = !!subscription;
const canPreview = useMemo(() => {
if (room && room.t !== 'c') {
return true;
}
if (allowAnonymousRead === true) {
return true;
}
if (canPreviewChannelRoom) {
return true;
}
return subscribed;
}, [allowAnonymousRead, canPreviewChannelRoom, room, subscribed]);
const {
handleUnreadBarJumpToButtonClick,
handleMarkAsReadButtonClick,
counter: [unread],
setUnreadCount,
setLastMessageDate,
debouncedMessageRead,
} = useHandleUnread(room, subscription);
const { handleDateScroll, bubbleRef, listStyle, ...bubbleDate } = useDateScroll();
const { innerRef: getMoreInnerRef } = useGetMore(room._id, isJumpingToMessage);
const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget();
const { uploads, isUploading } = useFileUpload();
const { messageListRef } = useMessageListNavigation();
const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop();
const {
handleNewMessageButtonClick,
handleJumpToRecentButtonClick,
handleComposerResize,
hasNewMessages,
debouncedClearNewMessagesOnScroll,
} = useHasNewMessages(room._id, user?._id, setShouldJumpToBottom, isAtBottom);
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
const innerRef = useMergedRefsV2(getMoreInnerRef, selectAndScrollRef, messageListRef, keepAtBottomRef);
const handleNavigateToPreviousMessage = useCallback((): void => {
chat.messageEditing.toPreviousMessage();
}, [chat.messageEditing]);
const handleNavigateToNextMessage = useCallback((): void => {
chat.messageEditing.toNextMessage();
}, [chat.messageEditing]);
const handleCloseFlexTab = useCallback(
(e: MouseEvent<HTMLElement>): void => {
/*
* check if the element is a button or anchor
* it considers the role as well
* usually, the flex tab is closed when clicking outside of it
* but if the user clicks on a button or anchor, we don't want to close the flex tab
* because the user could be actually trying to open the flex tab through those elements
*/
const checkElement = (element: HTMLElement | null): boolean => {
if (!element) {
return false;
}
if (element instanceof HTMLButtonElement || element.getAttribute('role') === 'button') {
return true;
}
if (element instanceof HTMLAnchorElement || element.getAttribute('role') === 'link') {
return true;
}
return checkElement(element.parentElement);
};
if (checkElement(e.target as HTMLElement)) {
return;
}
toolbox.closeTab();
},
[toolbox],
);
useGoToHomeOnRemoved(room, user?._id);
useReadMessageWindowEvents();
useQuoteMessageByUrl();
return (
<>
{!isLayoutEmbedded && room.announcement && <RoomAnnouncement announcement={room.announcement} />}
<Box key={room._id} className={['main-content-flex', listStyle]}>
<section
role='presentation'
className={`messages-container flex-tab-main-content ${admin ? 'admin' : ''}`}
id={`chat-window-${room._id}`}
onClick={hideFlexTab && handleCloseFlexTab}
>
<div className='messages-container-wrapper'>
<div className='messages-container-main' {...fileUploadTriggerProps}>
<DropTargetOverlay {...fileUploadOverlayProps} />
<Box position='absolute' width='full'>
{isUploading && <UploadProgressIndicator uploads={uploads} />}
{Boolean(unread) && (
<UnreadMessagesIndicator
count={unread}
onJumpButtonClick={handleUnreadBarJumpToButtonClick}
onMarkAsReadButtonClick={handleMarkAsReadButtonClick}
/>
)}
<BubbleDate ref={bubbleRef} {...bubbleDate} />
</Box>
<div className={['messages-box'].filter(isTruthy).join(' ')}>
<JumpToRecentMessageButton visible={hasNewMessages} onClick={handleNewMessageButtonClick} text={t('New_messages')} />
<JumpToRecentMessageButton
visible={hasMoreNextMessages}
onClick={handleJumpToRecentButtonClick}
text={t('Jump_to_recent_messages')}
/>
{!canPreview ? (
<div className='content room-not-found error-color'>
<div>{t('You_must_join_to_view_messages_in_this_channel')}</div>
</div>
) : null}
<div
className={[
'wrapper',
hasMoreNextMessages && 'has-more-next',
hideUsernames && 'hide-usernames',
!displayAvatars && 'hide-avatar',
]
.filter(isTruthy)
.join(' ')}
>
<MessageListErrorBoundary>
<CustomVirtuaScrollbars ref={innerRef} key={room._id}>
<MessageList
rid={room._id}
shouldJumpToBottom={shouldJumpToBottom}
setShouldJumpToBottom={setShouldJumpToBottom}
isAtBottom={isAtBottom}
isJumpingToMessage={isJumpingToMessage}
setIsJumpingToMessage={setIsJumpingToMessage}
canPreview={canPreview}
hasMorePreviousMessages={hasMorePreviousMessages}
isLoadingMoreMessages={isLoadingMoreMessages}
user={user}
room={room}
retentionPolicy={retentionPolicy}
hasMoreNextMessages={hasMoreNextMessages}
setUnreadCount={setUnreadCount}
setLastMessageDate={setLastMessageDate}
debouncedClearNewMessagesOnScroll={debouncedClearNewMessagesOnScroll}
handleDateScroll={handleDateScroll}
debouncedMessageRead={debouncedMessageRead}
setKeepAtBottom={setKeepAtBottom}
/>
</CustomVirtuaScrollbars>
</MessageListErrorBoundary>
</div>
</div>
<RoomComposer aria-label={t('Room_composer')}>
<ComposerContainer
subscription={subscription}
onResize={handleComposerResize}
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
onNavigateToNextMessage={handleNavigateToNextMessage}
onClickSelectAll={selectAllAndScrollToTop}
// TODO: send previewUrls param
// previewUrls={}
/>
</RoomComposer>
</div>
</div>
</section>
</Box>
</>
);
};
export default memo(RoomBody);

View File

@@ -0,0 +1,49 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { FlexItem, Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation } from 'react-i18next';
import RoomForewordUsernameList from './RoomForewordUsernameList';
export type RoomForewordProps = { user: IUser | null; room: IRoom };
const RoomForeword = ({ user, room }: RoomForewordProps) => {
const { t } = useTranslation();
if (!isDirectMessageRoom(room)) {
return (
<Box fontScale='c1' color='default' display='flex' justifyContent='center' marginBlock={8}>
{t('Start_of_conversation')}
</Box>
);
}
const usernames = room.usernames?.filter((username) => username !== user?.username);
if (!usernames || usernames.length < 1) {
return null;
}
return (
<Box is='div' flexGrow={1} display='flex' justifyContent='center' flexDirection='column' marginBlock={8}>
<FlexItem grow={1}>
<Box display='flex' alignItems='center' justifyContent='center'>
{usernames.map((username, index) => (
<Box is='span' marginInline={4} key={index}>
<UserAvatar size='x48' username={username} />
</Box>
))}
</Box>
</FlexItem>
<Box display='flex' color='default' fontScale='h4' flexGrow={1} justifyContent='center' marginBlock={16}>
{t('Direct_message_you_have_joined')}
</Box>
<Box is='div' flexGrow={1} display='flex' justifyContent='center'>
<RoomForewordUsernameList usernames={usernames} />
</Box>
</Box>
);
};
export default RoomForeword;

View File

@@ -0,0 +1,25 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Margins } from '@rocket.chat/fuselage';
import { useRouter } from '@rocket.chat/ui-contexts';
import RoomForewordUsernameListItem from './RoomForewordUsernameListItem';
export type RoomForewordUsernameListProps = { usernames: Array<NonNullable<IUser['username']>> };
const RoomForewordUsernameList = ({ usernames }: RoomForewordUsernameListProps) => {
const router = useRouter();
return (
<Margins inline={4}>
{usernames.map((username) => (
<RoomForewordUsernameListItem
username={username}
key={username}
href={router.getRoomRoute('d', { name: username }).path || undefined}
/>
))}
</Margins>
);
};
export default RoomForewordUsernameList;

View File

@@ -0,0 +1,25 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Icon, Tag, Skeleton } from '@rocket.chat/fuselage';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useUserInfoQuery } from '../../../../hooks/useUserInfoQuery';
export type RoomForewordUsernameListItemProps = {
href: string | undefined;
username: NonNullable<IUser['username']>;
};
const RoomForewordUsernameListItem = ({ username, href }: RoomForewordUsernameListItemProps) => {
const { data, isLoading, isError, isSuccess } = useUserInfoQuery({ username });
const displayName = useUserDisplayName({ name: data?.user?.name, username });
return (
<Tag icon={<Icon name='user' size='x20' />} data-username={username} large href={href}>
{isLoading && <Skeleton variant='rect' />}
{isError && username}
{isSuccess && displayName}
</Tag>
);
};
export default RoomForewordUsernameListItem;

View File

@@ -0,0 +1,56 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import RoomInvite from './RoomInviteBody';
import * as stories from './RoomInviteBody.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const appRoot = mockAppRoot().build();
describe('RoomInvite', () => {
const onAccept = jest.fn();
const onReject = jest.fn();
const inviter = {
username: 'rocket.cat',
name: 'Rocket Cat',
_id: 'rocket.cat',
};
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: appRoot });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should call onAccept when accept button is clicked', async () => {
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
expect(onAccept).toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
});
it('should call onReject when reject button is clicked', async () => {
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
await userEvent.click(screen.getByRole('button', { name: 'Reject' }));
expect(onReject).toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import RoomInvite from './RoomInviteBody';
const meta = {
component: RoomInvite,
parameters: {
layout: 'centered',
},
args: {
onAccept: action('onAccept'),
onReject: action('onReject'),
},
} satisfies Meta<typeof RoomInvite>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
inviter: {
username: 'rocket.cat',
name: 'Rocket Cat',
_id: 'rocket.cat',
},
},
};
export const WithInfoLink: Story = {
args: {
infoLink: {
label: 'Learn more',
href: 'https://rocket.chat',
},
inviter: {
username: 'rocket.cat',
name: 'Rocket Cat',
_id: 'rocket.cat',
},
},
};
export const Loading: Story = {
args: {
isLoading: true,
inviter: {
username: 'rocket.cat',
name: 'Rocket Cat',
_id: 'rocket.cat',
},
},
};

View File

@@ -0,0 +1,46 @@
import type { IInviteSubscription } from '@rocket.chat/core-typings';
import { Box, Button, Chip, States, StatesActions, StatesIcon, StatesLink, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation } from 'react-i18next';
export type RoomInviteBodyProps = {
isLoading?: boolean;
inviter: IInviteSubscription['inviter'];
infoLink?: {
label: string;
href: string;
};
onAccept: () => void;
onReject: () => void;
};
const RoomInviteBody = ({ inviter, infoLink, isLoading, onAccept, onReject }: RoomInviteBodyProps) => {
const { t } = useTranslation();
const { name, username } = inviter;
return (
<Box margin='auto'>
<States>
<StatesIcon name='mail' />
<StatesTitle>{t('Message_request')}</StatesTitle>
<StatesSubtitle>
<Box marginBlockEnd={8}>{t('You_have_been_invited_to_have_a_conversation_with')}</Box>
<Chip>
<UserAvatar username={username} size='x16' /> {name || username}
</Chip>
</StatesSubtitle>
<StatesActions>
<Button secondary danger loading={isLoading} onClick={onReject}>
{t('Reject')}
</Button>
<Button loading={isLoading} onClick={onAccept}>
{t('Accept')}
</Button>
</StatesActions>
{infoLink && <StatesLink href={infoLink.href}>{infoLink.label}</StatesLink>}
</States>
</Box>
);
};
export default RoomInviteBody;

View File

@@ -0,0 +1,35 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Bubble } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type UnreadMessagesIndicatorProps = {
count: number;
onJumpButtonClick: () => void;
onMarkAsReadButtonClick: () => void;
};
const indicatorStyle = css`
position: relative;
display: flex;
justify-content: center;
z-index: 3;
`;
const UnreadMessagesIndicator = ({ count, onJumpButtonClick, onMarkAsReadButtonClick }: UnreadMessagesIndicatorProps) => {
const { t } = useTranslation();
return (
<Box className={indicatorStyle} marginBlockStart={8}>
<Bubble
onClick={onJumpButtonClick}
onDismiss={onMarkAsReadButtonClick}
icon='arrow-up'
dismissProps={{ 'title': t('Mark_as_read'), 'aria-label': `${t('Mark_as_read')}` }}
>
{t('unread_messages_counter', { count })}
</Bubble>
</Box>
);
};
export default UnreadMessagesIndicator;

View File

@@ -0,0 +1,72 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Bubble } from '@rocket.chat/fuselage';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Upload } from '../../../../lib/chats/Upload';
export type UploadProgressIndicatorProps = {
uploads: readonly Upload[];
};
const UploadProgressIndicator = ({ uploads }: UploadProgressIndicatorProps) => {
const { t } = useTranslation();
const { percentage, count } = useMemo(() => {
const validUploads = uploads.filter((upload) => !upload.error);
const activeUploads = validUploads.filter((upload) => upload.percentage < 100);
if (activeUploads.length === 0) {
return { percentage: 0, count: 0 };
}
const totalPercentage = validUploads.reduce((sum, upload) => sum + upload.percentage, 0);
const avgPercentage = Math.round(totalPercentage / validUploads.length);
return {
percentage: avgPercentage,
count: activeUploads.length,
};
}, [uploads]);
const customClass = useMemo(
() => css`
position: relative;
display: flex;
justify-content: center;
z-index: 3;
& .rcx-bubble__item {
position: relative;
> span {
z-index: 2;
}
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: ${percentage}%;
height: 100%;
transition: width 0.3s ease-out;
background-color: var(--rcx-color-button-background-primary-press, #10529e);
}
}
`,
[percentage],
);
if (count === 0) {
return null;
}
return (
<Box className={customClass} marginBlockStart={8}>
<Bubble role='status'>{`${percentage}% ${t('Uploading__count__file', { count })}`}</Bubble>
</Box>
);
};
export default UploadProgressIndicator;

View File

@@ -0,0 +1 @@
export { default } from './UploadProgressIndicator';

View File

@@ -0,0 +1,273 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomInvite renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-b4escz"
>
<div
class="rcx-states"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
>
</i>
<h3
class="rcx-states__title"
>
Message_request
</h3>
<div
class="rcx-states__subtitle"
>
<div
class="rcx-box rcx-box--full rcx-css-ctk2ij"
>
You_have_been_invited_to_have_a_conversation_with
</div>
<button
class="rcx-box rcx-chip"
disabled=""
type="button"
>
<span
class="rcx-box rcx-chip__text rcx-css-trljwa"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
>
<img
alt="Avatar"
aria-hidden="true"
class="rcx-avatar__element rcx-avatar__element--x16"
data-username="rocket.cat"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
title="rocket.cat"
/>
</figure>
Rocket Cat
</span>
</button>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Reject
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Accept
</span>
</button>
</div>
</div>
</div>
</div>
</body>
`;
exports[`RoomInvite renders Loading without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-b4escz"
>
<div
class="rcx-states"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
>
</i>
<h3
class="rcx-states__title"
>
Message_request
</h3>
<div
class="rcx-states__subtitle"
>
<div
class="rcx-box rcx-box--full rcx-css-ctk2ij"
>
You_have_been_invited_to_have_a_conversation_with
</div>
<button
class="rcx-box rcx-chip"
disabled=""
type="button"
>
<span
class="rcx-box rcx-chip__text rcx-css-trljwa"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
>
<img
alt="Avatar"
aria-hidden="true"
class="rcx-avatar__element rcx-avatar__element--x16"
data-username="rocket.cat"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
title="rocket.cat"
/>
</figure>
Rocket Cat
</span>
</button>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--loading rcx-button--secondary-danger rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
>
</i>
Reject
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--loading rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
>
</i>
Accept
</span>
</button>
</div>
</div>
</div>
</div>
</body>
`;
exports[`RoomInvite renders WithInfoLink without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-b4escz"
>
<div
class="rcx-states"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
>
</i>
<h3
class="rcx-states__title"
>
Message_request
</h3>
<div
class="rcx-states__subtitle"
>
<div
class="rcx-box rcx-box--full rcx-css-ctk2ij"
>
You_have_been_invited_to_have_a_conversation_with
</div>
<button
class="rcx-box rcx-chip"
disabled=""
type="button"
>
<span
class="rcx-box rcx-chip__text rcx-css-trljwa"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
>
<img
alt="Avatar"
aria-hidden="true"
class="rcx-avatar__element rcx-avatar__element--x16"
data-username="rocket.cat"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
title="rocket.cat"
/>
</figure>
Rocket Cat
</span>
</button>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Reject
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Accept
</span>
</button>
</div>
<a
class="rcx-box rcx-box--full rcx-states__link"
href="https://rocket.chat"
>
Learn more
</a>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,41 @@
import type { DragEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
const hasFilesToUpload = (dataTransfer: DataTransfer): boolean => dataTransfer.types.includes('Files');
const hasURLToUpload = (dataTransfer: DataTransfer): boolean =>
dataTransfer.types.includes('text/uri-list') && dataTransfer.types.includes('text/html');
export const useDropTarget = (): {
triggerProps: {
onDragEnter: (event: DragEvent<Element>) => void;
};
overlayProps: {
visible: boolean;
onDismiss: () => void;
};
} => {
const [visible, setVisible] = useState(false);
const onDragEnter = useCallback((event: DragEvent) => {
event.stopPropagation();
if (!hasFilesToUpload(event.dataTransfer) && !hasURLToUpload(event.dataTransfer)) {
return;
}
setVisible(true);
}, []);
const onDismiss = useCallback(() => {
setVisible(false);
}, []);
const triggerProps = useMemo(() => ({ onDragEnter }), [onDragEnter]);
const overlayProps = useMemo(() => ({ visible, onDismiss }), [visible, onDismiss]);
return {
triggerProps,
overlayProps,
};
};

View File

@@ -0,0 +1,49 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
import type { Upload } from '../../../../lib/chats/Upload';
import { useChat } from '../../contexts/ChatContext';
const emptySubscribe = () => () => undefined;
const emptyUploads: readonly Upload[] = [];
const getEmptyUploads = () => emptyUploads;
const getEmptyBool = () => false;
export const useFileUpload = () => {
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
const store = chat.composer?.uploads;
const uploads = useSyncExternalStore(store?.subscribe ?? emptySubscribe, store?.get ?? getEmptyUploads);
const isProcessingUploads = useSyncExternalStore(store?.subscribe ?? emptySubscribe, store?.getProcessingUploads ?? getEmptyBool);
useEffect(() => {
store?.wipeFailedOnes();
return () => store?.clear();
}, [chat.action, store]);
const handleUploadFiles = useCallback(
(files: readonly File[]): void => {
chat.flows.uploadFiles({ files });
},
[chat],
);
const isUploading = uploads.length > 0 && uploads.some((upload) => !upload.url && !upload.error);
return useMemo(
() => ({
uploadsStore: store,
uploads,
hasUploads: uploads.length > 0,
isUploading,
isProcessingUploads,
handleUploadFiles,
}),
[store, uploads, isUploading, isProcessingUploads, handleUploadFiles],
);
};

View File

@@ -0,0 +1,96 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { usePermission, useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts';
import type { DragEvent, ReactNode } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import { useDropTarget } from './useDropTarget';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { useIsRoomOverMacLimit } from '../../../omnichannel/hooks/useIsRoomOverMacLimit';
import { useChat } from '../../contexts/ChatContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
export const useFileUploadDropTarget = (): readonly [
fileUploadTriggerProps: {
onDragEnter: (event: DragEvent<Element>) => void;
},
fileUploadOverlayProps: {
visible: boolean;
onDismiss: () => void;
enabled: boolean;
reason?: ReactNode;
},
] => {
const room = useRoom();
const { triggerProps, overlayProps } = useDropTarget();
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
const t = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled', true);
const user = useUser();
const postReadOnly = usePermission('post-readonly', room._id);
const fileUploadAllowedForUser = useMemo(
() => !roomCoordinator.readOnly(room, { username: user?.username }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[room, user?.username, postReadOnly],
);
const chat = useChat();
const subscription = useRoomSubscription();
const isEditing = useSyncExternalStore(
chat?.composer?.editing.subscribe ?? (() => () => undefined),
chat?.composer?.editing.get ?? (() => false),
);
const onFileDrop = useStableCallback(async (files: File[]) => {
const { getMimeType } = await import('../../../../../app/utils/lib/mimeTypes');
const getUniqueFiles = () => {
const uniqueFiles: File[] = [];
const st: Set<string> = new Set();
files.forEach((file) => {
const key = `${file.name}-${file.size}-${file.lastModified}`;
if (!st.has(key)) {
uniqueFiles.push(file);
st.add(key);
}
});
return uniqueFiles;
};
const uniqueFiles = getUniqueFiles();
const uploads = Array.from(uniqueFiles).map((file) => {
Object.defineProperty(file, 'type', { value: getMimeType(file.type, file.name) });
return file;
});
chat?.flows.uploadFiles({ files: uploads });
});
const allOverlayProps = useMemo(() => {
if (!fileUploadEnabled || isRoomOverMacLimit) {
return {
enabled: false,
reason: t('FileUpload_Disabled'),
...overlayProps,
} as const;
}
if (!fileUploadAllowedForUser || !subscription || isEditing) {
return {
enabled: false,
reason: t('error-not-allowed'),
...overlayProps,
} as const;
}
return {
enabled: true,
onFileDrop,
...overlayProps,
} as const;
}, [isEditing, fileUploadAllowedForUser, fileUploadEnabled, isRoomOverMacLimit, onFileDrop, overlayProps, subscription, t]);
return [triggerProps, allOverlayProps] as const;
};

View File

@@ -0,0 +1,141 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { useGetMore } from './useGetMore';
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
import { getBoundingClientRect } from '../../../../lib/scrolling';
jest.mock('../../../../lib/RoomHistoryManager', () => ({
RoomHistoryManager: {
isLoading: jest.fn(),
isLoadingNext: jest.fn(),
hasMore: jest.fn(),
hasMoreNext: jest.fn(),
getMore: jest.fn(),
getMoreNext: jest.fn(),
restoreScroll: jest.fn(),
on: jest.fn(() => () => undefined),
},
}));
jest.mock('../../../../lib/scrolling', () => ({
getBoundingClientRect: jest.fn(),
}));
const mockGetMore = jest.fn();
describe('useGetMore', () => {
it('should call getMore when scrolling near top and hasMore is true', async () => {
const root = mockAppRoot();
const Test = () => {
const [atBottom] = useState(false);
const { innerRef } = useGetMore('room-id', atBottom);
return (
<div ref={innerRef as any} style={{ height: '100px', overflowY: 'scroll' }} data-testid='scrollable-element'>
<div style={{ height: '800px' }}></div>
</div>
);
};
(RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(true);
(RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.getMore as jest.Mock).mockImplementation(mockGetMore);
(getBoundingClientRect as jest.Mock).mockReturnValue({
scrollTop: 10,
clientHeight: 100,
scrollHeight: 800,
});
render(<Test />, {
wrapper: root.build(),
});
const scrollableElement = screen.getByTestId('scrollable-element');
scrollableElement.scrollTop = 10;
// Simulate real user input before the scroll — the hook ignores observer / programmatic
// scroll events until the user has interacted with the list.
scrollableElement.dispatchEvent(new Event('wheel'));
scrollableElement.dispatchEvent(new Event('scroll'));
expect(screen.getByTestId('scrollable-element')).toBeInTheDocument();
await waitFor(() => {
expect(RoomHistoryManager.getMore).toHaveBeenCalledWith('room-id');
});
});
it('should not call getMore when the container has no size (hidden behind the contextual bar)', async () => {
const root = mockAppRoot();
const Test = () => {
const [atBottom] = useState(false);
const { innerRef } = useGetMore('room-id', atBottom);
return (
<div style={{ display: 'none' }}>
<div ref={innerRef as any} style={{ height: '100px', overflowY: 'scroll' }} data-testid='scrollable-element'>
<div style={{ height: '800px' }}></div>
</div>
</div>
);
};
(RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(true);
(RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.getMore as jest.Mock).mockClear();
(getBoundingClientRect as jest.Mock).mockReturnValue({
scrollTop: 0,
clientHeight: 0,
scrollHeight: 0,
});
render(<Test />, {
wrapper: root.build(),
});
const scrollableElement = screen.getByTestId('scrollable-element');
scrollableElement.dispatchEvent(new Event('wheel'));
scrollableElement.dispatchEvent(new Event('scroll'));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(RoomHistoryManager.getMore).not.toHaveBeenCalled();
});
it('should call getMoreNext when scrolling near bottom and hasMoreNext is true', () => {
const root = mockAppRoot();
(RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(true);
(RoomHistoryManager.getMoreNext as jest.Mock).mockImplementation(mockGetMore);
const Test = () => {
const [atBottom] = useState(false);
const { innerRef } = useGetMore('room-id', atBottom);
return (
<div ref={innerRef as any} style={{ height: '100px', overflowY: 'scroll' }} data-testid='scrollable-element'>
<div style={{ height: '800px' }}></div>
</div>
);
};
(getBoundingClientRect as jest.Mock).mockReturnValue({
scrollTop: 700,
clientHeight: 100,
scrollHeight: 800,
});
render(<Test />, {
wrapper: root.build(),
});
const scrollableElement = screen.getByTestId('scrollable-element');
scrollableElement.scrollTop = 700;
// Simulate real user input before the scroll — the hook ignores observer / programmatic
// scroll events until the user has interacted with the list.
scrollableElement.dispatchEvent(new Event('wheel'));
scrollableElement.dispatchEvent(new Event('scroll'));
expect(screen.getByTestId('scrollable-element')).toBeInTheDocument();
expect(RoomHistoryManager.getMoreNext).toHaveBeenCalledWith('room-id');
});
});

View File

@@ -0,0 +1,148 @@
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { useSearchParameter } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { flushSync } from 'react-dom';
import { withThrottling } from '../../../../../lib/utils/highOrderFunctions';
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
import { getBoundingClientRect } from '../../../../lib/scrolling';
export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {
const msgId = useSearchParameter('msg');
const ref = useSafeRefCallback(
useCallback(
(element: HTMLElement) => {
// Observers (MutationObserver, ResizeObserver) fire during the initial mount cascade
// as messages are inserted and the virtualizer measures itself. At that point scrollTop
// is still 0 because scroll-to-bottom hasn't run yet, which made checkPositionAndGetMore
// pull a second history page immediately after the first. Gate observer-driven calls on
// real user input (wheel / touch / scroll-affecting keys); programmatic scroll does not
// flip this flag.
let userInteracted = false;
const checkPositionAndGetMore = withThrottling({ wait: 100 })(async () => {
if (!element.isConnected) {
return;
}
if (isJumpingToMessage) {
return;
}
if (RoomHistoryManager.isLoading(rid)) {
return;
}
if (msgId && !RoomHistoryManager.isLoaded(rid)) {
RoomHistoryManager.getSurroundingMessages({ _id: msgId, rid });
return;
}
const { scrollTop, clientHeight, scrollHeight } = getBoundingClientRect(element);
if (clientHeight === 0) {
return;
}
const lastScrollTopRef = scrollTop;
const height = clientHeight;
const hasMore = RoomHistoryManager.hasMore(rid);
const hasMoreNext = RoomHistoryManager.hasMoreNext(rid);
if (hasMore === true && lastScrollTopRef <= height / 3) {
await RoomHistoryManager.getMore(rid);
if (isJumpingToMessage) {
return;
}
if (!element.isConnected) {
return;
}
flushSync(() => {
RoomHistoryManager.restoreScroll(rid);
});
} else if (hasMoreNext === true && Math.ceil(lastScrollTopRef) >= scrollHeight - height) {
await RoomHistoryManager.getMoreNext(rid);
}
});
// A window rebuild (jump to a message, jump to recent) re-enters that same cascade
// mid-session: messages reinsert and the pending programmatic scroll hasn't run, so an
// observer pass would page off a stale scrollTop of 0. Require fresh input after each
// one, and drop any trailing invocation already scheduled against the dead window.
const offRoomCleared = RoomHistoryManager.on('room-cleared', (clearedRid: string) => {
if (clearedRid === rid) {
userInteracted = false;
checkPositionAndGetMore.cancel();
}
});
const gatedCheck = () => {
// Surrounding-messages fetch (when navigating to ?msg=...) needs to fire once on
// the initial observer pass before the user has interacted.
const allowedByJumpToMessage = !!msgId && !RoomHistoryManager.isLoaded(rid);
if (!userInteracted && !allowedByJumpToMessage) {
return;
}
checkPositionAndGetMore();
};
const mutationObserver = new MutationObserver(gatedCheck);
mutationObserver.observe(element, { childList: true, subtree: true });
const observer = new ResizeObserver(gatedCheck);
observer.observe(element);
const markInteracted = () => {
userInteracted = true;
};
const handleKeydown = (e: KeyboardEvent) => {
if (
e.key === 'PageUp' ||
e.key === 'PageDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Home' ||
e.key === 'End'
) {
userInteracted = true;
}
};
const handleScroll = () => {
if (!userInteracted) {
return;
}
checkPositionAndGetMore();
};
const parent = element.parentElement;
parent?.addEventListener('pointerdown', markInteracted);
element.addEventListener('wheel', markInteracted, { passive: true });
element.addEventListener('keydown', handleKeydown);
element.addEventListener('scroll', handleScroll, { passive: true });
return () => {
offRoomCleared();
observer.disconnect();
mutationObserver.disconnect();
checkPositionAndGetMore.cancel();
parent?.removeEventListener('pointerdown', markInteracted);
element.removeEventListener('wheel', markInteracted);
element.removeEventListener('keydown', handleKeydown);
element.removeEventListener('scroll', handleScroll);
};
},
[isJumpingToMessage, msgId, rid],
),
);
return {
innerRef: ref,
};
};

View File

@@ -0,0 +1,55 @@
import { isOmnichannelRoom, type IRoom } from '@rocket.chat/core-typings';
import { useRouter, useStream, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { roomsQueryKeys } from '../../../../lib/queryKeys';
import { useOmnichannelCloseRoute } from '../../../omnichannel/hooks/useOmnichannelCloseRoute';
export function useGoToHomeOnRemoved(room: IRoom, userId?: string): void {
const router = useRouter();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const subscribeToNotifyUser = useStream('notify-user');
const { t } = useTranslation();
const { navigateHome } = useOmnichannelCloseRoute();
useEffect(() => {
if (!userId) {
return;
}
return subscribeToNotifyUser(`${userId}/subscriptions-changed`, (event, subscription) => {
if (event === 'removed' && subscription.rid === room._id) {
queryClient.invalidateQueries({ queryKey: roomsQueryKeys.room(room._id) });
if (isOmnichannelRoom({ t: room.t })) {
navigateHome();
return;
}
dispatchToastMessage({
type: 'info',
message: t('You_have_been_removed_from__roomName_', {
roomName: room?.fname || room?.name || '',
}),
});
router.navigate('/home');
}
});
}, [
userId,
router,
subscribeToNotifyUser,
room._id,
room.t,
room?.fname,
room?.name,
t,
dispatchToastMessage,
queryClient,
navigateHome,
]);
}

View File

@@ -0,0 +1,213 @@
import type { IEditedMessage, IMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { clientCallbacks } from '@rocket.chat/ui-client';
import { renderHook, act } from '@testing-library/react';
import { useHasNewMessages } from './useHasNewMessages';
jest.mock('../../contexts/ChatContext', () => ({
useChat: () => ({
composer: { focus: jest.fn() },
}),
}));
jest.mock('../../../../lib/RoomHistoryManager', () => ({
RoomHistoryManager: {
clear: jest.fn(),
getMoreIfIsEmpty: jest.fn(),
hasMoreNext: jest.fn(),
},
}));
describe('useHasNewMessages', () => {
const isAtBottom = { current: true };
const setShouldJumpToBottom = jest.fn();
const rid = 'room-id';
const uid = 'current-user-id';
beforeEach(() => {
jest.clearAllMocks();
clientCallbacks.remove('streamNewMessage', rid);
});
afterEach(() => {
clientCallbacks.remove('streamNewMessage', rid);
});
it('should NOT show new messages button when user sends their own message', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const ownMsg: IMessage = {
_id: 'msg-1',
rid,
u: { _id: uid, username: 'current-user', name: 'Current User' },
msg: 'Hello',
ts: new Date(),
_updatedAt: new Date(),
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(ownMsg));
});
expect(result.current.hasNewMessages).toBe(false);
});
it('should NOT show new messages button when user sends own message during race condition (not at bottom)', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const ownMsg: IMessage = {
_id: 'msg-race',
rid,
u: { _id: uid, username: 'current-user', name: 'Current User' },
msg: 'Message sent during scroll',
ts: new Date(),
_updatedAt: new Date(),
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(ownMsg));
});
expect(result.current.hasNewMessages).toBe(false);
});
it('should show new messages button when another user sends a message and user is NOT at bottom', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, { current: false }), {
wrapper: mockAppRoot().build(),
});
const otherUserMsg: IMessage = {
_id: 'msg-2',
rid,
u: { _id: 'other-user-id', username: 'other-user', name: 'Other User' },
msg: 'Hello from another user',
ts: new Date(),
_updatedAt: new Date(),
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(otherUserMsg));
});
expect(result.current.hasNewMessages).toBe(true);
});
it('should NOT show new messages button when another user sends a message but user IS at bottom', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const otherUserMsg: IMessage = {
_id: 'msg-3',
rid,
u: { _id: 'other-user-id', username: 'other-user', name: 'Other User' },
msg: 'Hello from another user',
ts: new Date(),
_updatedAt: new Date(),
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(otherUserMsg));
});
expect(result.current.hasNewMessages).toBe(false);
});
it('should ignore edited messages in streamNewMessage', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const editedMsg: IEditedMessage = {
_id: 'msg-4',
rid,
u: { _id: 'other-user-id', username: 'other-user', name: 'Other User' },
msg: 'Edited message',
ts: new Date(),
_updatedAt: new Date(),
editedAt: new Date(),
editedBy: { _id: 'other-user-id', username: 'other-user' },
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(editedMsg));
});
expect(result.current.hasNewMessages).toBe(false);
});
it('should ignore thread messages in streamNewMessage', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const threadMsg: IMessage = {
_id: 'msg-5',
rid,
tmid: 'parent-thread-id',
u: { _id: 'other-user-id', username: 'other-user', name: 'Other User' },
msg: 'Thread reply',
ts: new Date(),
_updatedAt: new Date(),
};
const callbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
callbacks.forEach((callback) => callback(threadMsg));
});
expect(result.current.hasNewMessages).toBe(false);
});
it('should NOT jump to bottom when streamNewMessage fires for current user editing their own message', () => {
renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, isAtBottom), { wrapper: mockAppRoot().build() });
const editedOwnMsg: IEditedMessage = {
_id: 'msg-edit',
rid,
u: { _id: uid, username: 'current-user', name: 'Current User' },
msg: 'Edited message',
ts: new Date(),
_updatedAt: new Date(),
editedAt: new Date(),
editedBy: { _id: uid, username: 'current-user' },
};
const streamNewMessageCallbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
streamNewMessageCallbacks.forEach((callback) => callback(editedOwnMsg));
});
expect(setShouldJumpToBottom).not.toHaveBeenCalled();
});
it('should clear hasNewMessages when streamNewMessage fires for current users message', () => {
const { result } = renderHook(() => useHasNewMessages(rid, uid, setShouldJumpToBottom, { current: false }), {
wrapper: mockAppRoot().build(),
});
const otherUserMsg: IMessage = {
_id: 'msg-6',
rid,
u: { _id: 'other-user-id', username: 'other-user', name: 'Other User' },
msg: 'Hello',
ts: new Date(),
_updatedAt: new Date(),
};
const streamCallbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
streamCallbacks.forEach((callback) => callback(otherUserMsg));
});
expect(result.current.hasNewMessages).toBe(true);
const ownMsg: IMessage = {
_id: 'msg-7',
rid,
u: { _id: uid, username: 'current-user', name: 'Current User' },
msg: 'My reply',
ts: new Date(),
_updatedAt: new Date(),
};
const streamNewMessageCallbacks = clientCallbacks.getCallbacks('streamNewMessage');
act(() => {
streamNewMessageCallbacks.forEach((callback) => callback(ownMsg));
});
expect(result.current.hasNewMessages).toBe(false);
expect(setShouldJumpToBottom).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,90 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { clientCallbacks } from '@rocket.chat/ui-client';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
import { useChat } from '../../contexts/ChatContext';
export const useHasNewMessages = (
rid: string,
uid: string | undefined,
setShouldJumpToBottom: Dispatch<SetStateAction<boolean>>,
isAtBottom: MutableRefObject<boolean>,
) => {
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
const [hasNewMessages, setHasNewMessages] = useState(false);
const handleNewMessageButtonClick = useCallback(() => {
setShouldJumpToBottom(true);
setHasNewMessages(false);
chat.composer?.focus();
}, [setShouldJumpToBottom, chat.composer]);
const handleJumpToRecentButtonClick = useCallback(async () => {
RoomHistoryManager.clear(rid);
await RoomHistoryManager.getMoreIfIsEmpty(rid);
setShouldJumpToBottom(true);
}, [setShouldJumpToBottom, rid]);
const handleComposerResize = useCallback((): void => {
setShouldJumpToBottom(true);
setHasNewMessages(false);
}, [setShouldJumpToBottom]);
useEffect(() => {
clientCallbacks.add(
'streamNewMessage',
(msg: IMessage) => {
if (rid !== msg.rid || isEditedMessage(msg) || msg.tmid) {
return;
}
if (msg.u._id === uid) {
// If there are more next messages, jump to the recent messages
if (RoomHistoryManager.hasMoreNext(rid)) {
return handleJumpToRecentButtonClick();
}
setShouldJumpToBottom(true);
setHasNewMessages(false);
return;
}
if (!isAtBottom.current) {
setHasNewMessages(true);
}
},
clientCallbacks.priority.MEDIUM,
rid,
);
return () => {
clientCallbacks.remove('streamNewMessage', rid);
};
}, [handleJumpToRecentButtonClick, isAtBottom, rid, setShouldJumpToBottom, uid]);
const debouncedClearNewMessagesOnScroll = useDebouncedCallback(
() => {
if (isAtBottom.current) {
setHasNewMessages(false);
}
},
100,
[],
);
return {
debouncedClearNewMessagesOnScroll,
handleNewMessageButtonClick,
handleJumpToRecentButtonClick,
handleComposerResize,
hasNewMessages,
};
};

View File

@@ -0,0 +1,40 @@
import { renderHook } from '@testing-library/react';
import { useIsAtBottomRef } from './useIsAtBottomRef';
import { RoomManager } from '../../../../lib/RoomManager';
jest.mock('../../../../lib/RoomManager', () => ({
RoomManager: { getStore: jest.fn() },
}));
const mockStore = (store: { atBottom: boolean } | undefined) => (RoomManager.getStore as jest.Mock).mockReturnValue(store);
describe('useIsAtBottomRef', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('starts at the bottom when the room has no stored position yet', () => {
mockStore(undefined);
const { result } = renderHook(() => useIsAtBottomRef('rid'));
expect(result.current.current).toBe(true);
});
it('starts away from the bottom when the room was left scrolled mid-history', () => {
mockStore({ atBottom: false });
const { result } = renderHook(() => useIsAtBottomRef('rid'));
expect(result.current.current).toBe(false);
});
it('starts at the bottom when the room was left at the bottom', () => {
mockStore({ atBottom: true });
const { result } = renderHook(() => useIsAtBottomRef('rid'));
expect(result.current.current).toBe(true);
});
});

View File

@@ -0,0 +1,9 @@
import type { RefObject } from 'react';
import { useRef } from 'react';
import { RoomManager } from '../../../../lib/RoomManager';
/** Starting from `true` makes `useKeepAtBottom` pull a restored mid-history position down to the latest messages, so seed it from the position persisted for the room being opened. */
export const useIsAtBottomRef = (rid: string): RefObject<boolean> => {
return useRef<boolean>(RoomManager.getStore(rid)?.atBottom ?? true);
};

View File

@@ -0,0 +1,28 @@
import { useSearchParameter } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useChat } from '../../contexts/ChatContext';
export const useQuoteMessageByUrl = () => {
const replyMID = useSearchParameter('reply');
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
useEffect(() => {
if (!replyMID) {
return;
}
chat.data.getMessageByID(replyMID).then((message) => {
if (!message) {
return;
}
chat.composer?.quoteMessage(message);
});
}, [chat.data, chat.composer, replyMID]);
};

View File

@@ -0,0 +1,11 @@
import { useEffect } from 'react';
import { useChat } from '../../contexts/ChatContext';
export function useReadMessageWindowEvents() {
const chat = useChat();
useEffect(() => {
return chat?.readStateManager.handleWindowEvents();
}, [chat?.readStateManager]);
}

View File

@@ -0,0 +1,15 @@
import { useRef } from 'react';
import { useToggleSelectAll } from '../../MessageList/contexts/SelectedMessagesContext';
export const useSelectAllAndScrollToTop = () => {
const ref = useRef<HTMLElement>(null);
const handleToggleAll = useToggleSelectAll();
const selectAllAndScrollToTop = () => {
ref.current?.scrollTo({ top: 0, behavior: 'smooth' });
handleToggleAll();
};
return { innerRef: ref, selectAllAndScrollToTop };
};

View File

@@ -0,0 +1,29 @@
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import type { MutableRefObject } from 'react';
import type { VirtualizerHandle } from 'virtua';
import { RoomManager } from '../../../../lib/RoomManager';
type UseStoreScrollPositionProps = {
rid: string;
isAtBottom: MutableRefObject<boolean>;
virtualizerRef: MutableRefObject<VirtualizerHandle | null>;
};
export function useStoreScrollPosition({ rid, isAtBottom, virtualizerRef }: UseStoreScrollPositionProps) {
return useDebouncedCallback(
() => {
const scroll = virtualizerRef.current?.scrollOffset;
if (scroll == null) {
return;
}
const store = RoomManager.getStore(rid);
store?.update({ scroll, atBottom: isAtBottom.current });
},
100,
[rid, isAtBottom, virtualizerRef],
);
}

View File

@@ -0,0 +1,146 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { useRouter } from '@rocket.chat/ui-contexts';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { withDebouncing } from '../../../../../lib/utils/highOrderFunctions';
import { RoomHistoryManager, useRoomHistoryState } from '../../../../lib/RoomHistoryManager';
import { useOpenedRoomUnreadSince } from '../../../../lib/RoomManager';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { setMessageJumpQueryStringParameter } from '../../../../lib/utils/setMessageJumpQueryStringParameter';
import { Messages } from '../../../../stores';
import { useChat } from '../../contexts/ChatContext';
interface IUnreadMessages {
count: number;
since: Date;
}
const useUnreadMessages = (room: IRoom): readonly [data: IUnreadMessages | undefined, setUnreadCount: Dispatch<SetStateAction<number>>] => {
const notLoadedCount = useRoomHistoryState(room._id, (state) => state.unreadNotLoaded);
const [loadedCount, setLoadedCount] = useState(0);
const count = useMemo(() => notLoadedCount + loadedCount, [notLoadedCount, loadedCount]);
const since = useOpenedRoomUnreadSince();
return useMemo(() => {
if (count && since) {
return [{ count, since }, setLoadedCount];
}
return [undefined, setLoadedCount];
}, [count, since]);
};
export const useHandleUnread = (
room: IRoom,
subscription?: ISubscription,
): {
handleUnreadBarJumpToButtonClick: () => void;
handleMarkAsReadButtonClick: () => void;
counter: readonly [number, Date | undefined];
setUnreadCount: Dispatch<SetStateAction<number>>;
setLastMessageDate: Dispatch<SetStateAction<Date | undefined>>;
debouncedMessageRead: () => void;
} => {
const subscribed = Boolean(subscription);
const [unread, setUnreadCount] = useUnreadMessages(room);
const [lastMessageDate, setLastMessageDate] = useState<Date | undefined>();
const chat = useChat();
const findFirstMessage = Messages.use((state) => state.findFirst);
const filterMessages = Messages.use((state) => state.filter);
if (!chat) {
throw new Error('No ChatContext provided');
}
const handleUnreadBarJumpToButtonClick = useCallback(() => {
const rid = room._id;
const { firstUnread } = RoomHistoryManager.getRoom(rid);
let message = firstUnread;
if (!message) {
message = findFirstMessage(
(record) =>
record.rid === rid && record.ts.getTime() > (unread?.since.getTime() ?? -Infinity) && (!record.tmid || record.tshow === true),
(a, b) => a.ts.getTime() - b.ts.getTime(),
);
}
if (!message) {
return;
}
setMessageJumpQueryStringParameter(message?._id, 'jumpToUnread');
chat.readStateManager.markAsRead();
setUnreadCount(0);
}, [room._id, setUnreadCount, findFirstMessage, unread?.since, chat.readStateManager]);
const handleMarkAsReadButtonClick = useCallback(() => {
chat.readStateManager.markAsRead();
setUnreadCount(0);
}, [chat.readStateManager, setUnreadCount]);
useEffect(() => {
if (!subscribed) {
setUnreadCount(0);
return;
}
const count = filterMessages(
(record) =>
record.rid === room._id &&
!!lastMessageDate &&
record.ts.getTime() <= lastMessageDate?.getTime() &&
record.ts.getTime() > (subscription?.ls?.getTime() ?? -Infinity),
).length;
setUnreadCount(count);
}, [filterMessages, lastMessageDate, room._id, setUnreadCount, subscribed, subscription?.ls]);
const router = useRouter();
const debouncedMessageRead = useMemo(
() =>
withDebouncing({ wait: 500 })(() => {
if (subscribed) {
chat.readStateManager.attemptMarkAsRead();
}
}),
[chat.readStateManager, subscribed],
);
useEffect(
() =>
router.subscribeToRouteChange(() => {
const routeName = router.getRouteName();
if (!routeName || !roomCoordinator.isRouteNameKnown(routeName)) {
return;
}
debouncedMessageRead();
}),
[debouncedMessageRead, router],
);
useEffect(() => {
if (subscription?.alert || subscription?.unread || subscribed) {
debouncedMessageRead();
}
}, [debouncedMessageRead, subscription?.alert, subscription?.unread, subscribed]);
useEffect(() => {
if (!unread?.count) {
return debouncedMessageRead();
}
}, [debouncedMessageRead, room._id, unread?.count]);
return {
handleUnreadBarJumpToButtonClick,
handleMarkAsReadButtonClick,
counter: [unread?.count ?? 0, unread?.since] as const,
setUnreadCount,
setLastMessageDate,
debouncedMessageRead,
};
};