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,266 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { ReactNode } from 'react';
import type { MessageListProps } from './MessageList';
import { MessageList } from './MessageList';
import { useMessages } from './hooks/useMessages';
import { RoomManager } from '../../../lib/RoomManager';
import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId';
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
scrollTo: jest.fn(),
findItemIndex: jest.fn((offset: number) => offset),
scrollOffset: 0,
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='message-list' onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)} {...props}>
{Children.map(children, (child) => (child ? <div>{child}</div> : child))}
</div>
);
},
),
};
});
jest.mock('@rocket.chat/fuselage-hooks', () => ({
useDebouncedCallback: (callback: (...args: any[]) => void) => callback,
}));
jest.mock('../../../lib/RoomManager', () => ({
RoomManager: {
getStore: jest.fn(),
},
}));
jest.mock('./hooks/useMessages', () => ({
useMessages: jest.fn(),
}));
jest.mock('./hooks/useTryToJumpToMessage', () => jest.fn());
jest.mock('../hooks/useFirstUnreadMessageId', () => ({
useFirstUnreadMessageId: jest.fn(),
}));
jest.mock('../contexts/RoomContext', () => ({
useRoomSubscription: jest.fn(() => undefined),
}));
jest.mock('../contexts/ChatContext', () => ({
useChat: jest.fn(() => ({
readStateManager: {
setIsUnreadMarkVisibleCallback: jest.fn(),
},
})),
}));
jest.mock('./MessageListItem', () => ({
MessageListItem: ({ message }: { message: IMessage }) => (
<div role='listitem' data-testid='message-list-item'>
{message.msg}
</div>
),
}));
jest.mock('./providers/MessageListProvider', () => ({ children }: { children: ReactNode }) => <>{children}</>);
jest.mock('../providers/SelectedMessagesProvider', () => ({
SelectedMessagesProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
jest.mock('../body/LoadingMessagesIndicator', () => () => <span>Loading</span>);
jest.mock('../body/RetentionPolicyWarning', () => () => <span>Retention policy warning</span>);
jest.mock('../body/RoomForeword/RoomForeword', () => () => <span>Room foreword</span>);
const createMessage = (_id: string, ts = new Date()): IMessage =>
({
_id,
rid: 'room-id',
msg: `message ${_id}`,
ts,
_updatedAt: ts,
u: {
_id: 'user-id',
username: 'user',
},
}) as IMessage;
const defaultProps = {
rid: 'room-id' as IRoom['_id'],
canPreview: true,
hasMorePreviousMessages: false,
isLoadingMoreMessages: false,
user: { _id: 'user-id', username: 'user' } as IUser,
room: { _id: 'room-id', t: 'c' } as IRoom,
retentionPolicy: undefined,
hasMoreNextMessages: false,
shouldJumpToBottom: false,
setShouldJumpToBottom: jest.fn(),
isAtBottom: { current: false },
isJumpingToMessage: false,
setIsJumpingToMessage: jest.fn(),
setUnreadCount: jest.fn(),
setLastMessageDate: jest.fn(),
debouncedClearNewMessagesOnScroll: jest.fn(),
handleDateScroll: jest.fn(),
debouncedMessageRead: jest.fn(),
setKeepAtBottom: jest.fn(),
};
describe('MessageList scroll position', () => {
let root: ReturnType<typeof mockAppRoot>;
beforeEach(() => {
jest.clearAllMocks();
mockVirtualizerHandle.scrollToIndex.mockClear();
mockVirtualizerHandle.scrollTo.mockClear();
mockVirtualizerHandle.findItemIndex.mockImplementation((offset: number) => offset);
mockVirtualizerHandle.scrollOffset = 0;
mockVirtualizerHandle.scrollSize = 1000;
mockVirtualizerHandle.viewportSize = 300;
(useMessages as jest.Mock).mockReturnValue([createMessage('message-1'), createMessage('message-2')]);
(useFirstUnreadMessageId as jest.Mock).mockReturnValue(undefined);
root = mockAppRoot().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true);
});
it('should restore room scroll position based on store', () => {
const store = {
scroll: 123,
atBottom: false,
update: jest.fn(),
};
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(mockVirtualizerHandle.scrollTo).toHaveBeenCalledWith(123);
expect(mockVirtualizerHandle.scrollToIndex).not.toHaveBeenCalled();
});
it('should jump to bottom if atBottom is true', () => {
const store = {
scroll: 123,
atBottom: true,
update: jest.fn(),
};
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
render(<MessageList {...defaultProps} shouldJumpToBottom={true} />, { wrapper: root.build() });
expect(mockVirtualizerHandle.scrollToIndex).toHaveBeenCalledWith(2, { align: 'center' });
});
it('should jump to bottom if unreads are present', () => {
const store = {
scroll: 123,
atBottom: false,
update: jest.fn(),
};
(useFirstUnreadMessageId as jest.Mock).mockReturnValue('message-1');
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
render(<MessageList {...defaultProps} shouldJumpToBottom={true} />, { wrapper: root.build() });
expect(defaultProps.setShouldJumpToBottom).toHaveBeenCalledWith(true);
});
it('should do nothing if no previous scroll position is stored', () => {
const store = {
scroll: undefined,
atBottom: false,
update: jest.fn(),
};
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(mockVirtualizerHandle.scrollToIndex).not.toHaveBeenCalled();
expect(mockVirtualizerHandle.scrollTo).not.toHaveBeenCalled();
});
it('should update store based on scroll position', async () => {
const store = {
scroll: 1,
atBottom: false,
update: jest.fn(),
};
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
mockVirtualizerHandle.scrollOffset = 50;
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
fireEvent.scroll(screen.getByTestId('message-list'));
await waitFor(() => {
expect(store.update).toHaveBeenCalledWith({ scroll: 50, atBottom: false });
});
});
});
describe('MessageList accessibility', () => {
let root: ReturnType<typeof mockAppRoot>;
beforeEach(() => {
jest.clearAllMocks();
(useMessages as jest.Mock).mockReturnValue([createMessage('message-1'), createMessage('message-2')]);
(useFirstUnreadMessageId as jest.Mock).mockReturnValue(undefined);
(RoomManager.getStore as jest.Mock).mockReturnValue({ scroll: undefined, atBottom: false, update: jest.fn() });
root = mockAppRoot().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true);
});
it('should render a labelled list exposing every message as a list item', () => {
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
const list = screen.getByRole('list');
expect(list).toHaveAccessibleName();
// the two messages plus the foreword
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
});
const states: [string, Partial<MessageListProps>][] = [
['default', {}],
['loading previous messages', { hasMorePreviousMessages: true, isLoadingMoreMessages: true }],
['loading next messages', { hasMoreNextMessages: true, isLoadingMoreMessages: true }],
[
'showing the retention policy warning',
{ retentionPolicy: { enabled: true, isActive: true, filesOnly: false, excludePinned: false, ignoreThreads: false, maxAge: 30 } },
],
['without preview permission', { canPreview: false }],
];
it.each(states)('should have no accessibility violations when %s', async (_state, props) => {
const { container } = render(<MessageList {...defaultProps} {...props} />, { wrapper: root.build() });
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@@ -0,0 +1,330 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { isThreadMessage } from '@rocket.chat/core-typings';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { MessageTypes } from '@rocket.chat/message-types';
import { useSearchParameter, useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { VirtualizerHandle } from 'virtua';
import { VList } from 'virtua';
import { MessageListItem } from './MessageListItem';
import { useRoomSubscription } from '../contexts/RoomContext';
import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId';
import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider';
import { useMessages } from './hooks/useMessages';
import useTryToJumpToMessage from './hooks/useTryToJumpToMessage';
import { isMessageSequential } from './lib/isMessageSequential';
import MessageListProvider from './providers/MessageListProvider';
import { RoomManager } from '../../../lib/RoomManager';
import LoadingMessagesIndicator from '../body/LoadingMessagesIndicator';
import RetentionPolicyWarning from '../body/RetentionPolicyWarning';
import RoomForeword from '../body/RoomForeword/RoomForeword';
import { useStoreScrollPosition } from '../body/hooks/useStoreScrollPosition';
import { useChat } from '../contexts/ChatContext';
import type { RetentionPolicy } from '../hooks/useRetentionPolicy';
import { useKeepMountedMessages } from './hooks/useKeepMountedMessages';
export type MessageListProps = {
rid: IRoom['_id'];
canPreview: boolean;
hasMorePreviousMessages: boolean;
isLoadingMoreMessages: boolean;
user: IUser | null;
room: IRoom;
retentionPolicy: RetentionPolicy | undefined;
hasMoreNextMessages: boolean;
shouldJumpToBottom: boolean;
isAtBottom: MutableRefObject<boolean>;
isJumpingToMessage: boolean;
setIsJumpingToMessage: Dispatch<SetStateAction<boolean>>;
setUnreadCount: Dispatch<SetStateAction<number>>;
setLastMessageDate: Dispatch<SetStateAction<Date | undefined>>;
debouncedClearNewMessagesOnScroll: () => void;
handleDateScroll: (topMessage: IMessage | undefined, offset: number) => void;
setShouldJumpToBottom: Dispatch<SetStateAction<boolean>>;
debouncedMessageRead: () => void;
setKeepAtBottom: (keepAtBottom: () => void) => void;
};
export const MessageList = function MessageList({
rid,
canPreview,
hasMorePreviousMessages,
isLoadingMoreMessages,
user,
room,
retentionPolicy,
hasMoreNextMessages,
shouldJumpToBottom,
setShouldJumpToBottom,
isAtBottom,
isJumpingToMessage,
setIsJumpingToMessage,
setUnreadCount,
setLastMessageDate,
debouncedClearNewMessagesOnScroll,
handleDateScroll,
debouncedMessageRead,
setKeepAtBottom,
}: MessageListProps) {
// Prepend ref needed for adjusting the message list shift
// https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
const isPrepend = useRef<boolean>(false);
useLayoutEffect(() => {
isPrepend.current = false;
// FIXME: isAtBottom should be better calculated, as it does no alwas represent the correct value
if (hasMoreNextMessages) {
isAtBottom.current = false;
}
});
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const lastScrollSizeRef = useRef(0);
const messages = useMessages({ rid });
const messagesLength = canPreview ? messages.length + 1 : messages.length;
useEffect(() => {
setKeepAtBottom(() => {
if (virtualizerRef.current) {
virtualizerRef.current.scrollToIndex(messagesLength, {
align: 'end',
});
}
});
}, [messagesLength, setKeepAtBottom]);
const keepMountedMessages = useKeepMountedMessages(messages, canPreview);
useTryToJumpToMessage({ rid, virtualizerRef, setIsJumpingToMessage, messages });
const handlePrepend = useCallback(
(offset: number) => {
if (!isRoomInitialized.current) {
return;
}
// If the offset is less than 200, it means the user is reaching the top of the list,
// so the prepend need to be enabled for smooth scrolling,
// if the prepend is enabled when a new message is added, the list will misalign.
if (offset < 200) {
isPrepend.current = true;
}
const scrollSize = virtualizerRef.current?.scrollSize ?? 0;
const viewportSize = virtualizerRef.current?.viewportSize ?? 0;
if (hasMoreNextMessages) {
isAtBottom.current = false;
return;
}
if (scrollSize >= viewportSize) {
isAtBottom.current = true;
}
isAtBottom.current = offset - scrollSize + viewportSize >= -60;
if (shouldJumpToBottom && isAtBottom.current) {
setShouldJumpToBottom(false);
}
},
[isAtBottom, setShouldJumpToBottom, shouldJumpToBottom, hasMoreNextMessages],
);
const isRoomInitialized = useRef<boolean>(false);
const firstUnreadMessageId = useFirstUnreadMessageId();
const messageJumpParam = useSearchParameter('msg');
// Scroll to bottom
useEffect(() => {
if (isJumpingToMessage || messageJumpParam) {
if (!isRoomInitialized.current) {
// Jump to message will have to load messages, thus removing the need to initialize the room here
isRoomInitialized.current = true;
setShouldJumpToBottom(false);
}
return;
}
if (!isRoomInitialized.current) {
const store = RoomManager.getStore(rid);
if (!store) {
return;
}
if (!store?.atBottom && store?.scroll !== undefined) {
// When unreads are present, always scroll to bottom
if (firstUnreadMessageId) {
isRoomInitialized.current = true;
setShouldJumpToBottom(true);
return;
}
setShouldJumpToBottom(false);
virtualizerRef.current?.scrollTo(store?.scroll);
isAtBottom.current = false;
isRoomInitialized.current = true;
return;
}
setShouldJumpToBottom(true);
isRoomInitialized.current = true;
}
const handle = virtualizerRef.current;
const lastItemIndex = messages.length - 1;
if (shouldJumpToBottom === true) {
// When new messages arrive, this effect is triggered, but the latest message is not on the index, so it scrolls to the previous index
// TODO: Find if there is a better way to scroll to the latest message
handle?.scrollToIndex(lastItemIndex + 1, {
align: 'center',
});
}
// If new messages arrive and is at bottom, scroll to keep at bottom.
if (isAtBottom.current && lastScrollSizeRef.current !== handle?.scrollSize && !isLoadingMoreMessages) {
lastScrollSizeRef.current = handle?.scrollSize ?? 0;
handle?.scrollToIndex(lastItemIndex + 1, {
align: 'end',
});
}
}, [
isAtBottom,
messages,
shouldJumpToBottom,
isJumpingToMessage,
isLoadingMoreMessages,
messageJumpParam,
rid,
firstUnreadMessageId,
setShouldJumpToBottom,
]);
const storeScrollPosition = useStoreScrollPosition({ rid, isAtBottom, virtualizerRef });
const subscription = useRoomSubscription();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300);
const { t } = useTranslation();
const unreadMarkIndex = useRef<number | null>(null);
const chat = useChat();
const isUnreadMarkVisible = useCallback(() => {
if (unreadMarkIndex.current === null) {
return false;
}
const handle = virtualizerRef.current;
if (!handle) {
return false;
}
const listIndexOffset = canPreview ? 1 : 0;
const markListIndex = listIndexOffset + unreadMarkIndex.current;
const viewStartIndex = handle.findItemIndex(handle.scrollOffset);
const viewEndIndex = handle.findItemIndex(handle.scrollOffset + handle.viewportSize);
const minIndex = Math.min(viewStartIndex, viewEndIndex);
const maxIndex = Math.max(viewStartIndex, viewEndIndex);
const isVisible = markListIndex >= minIndex && markListIndex <= maxIndex;
return isVisible;
}, [canPreview]);
useEffect(() => {
if (chat) {
chat.readStateManager.setIsUnreadMarkVisibleCallback(isUnreadMarkVisible);
}
}, [chat, isUnreadMarkVisible]);
const handleTopVisibleMessage = useDebouncedCallback(
(topMessage: IMessage | undefined) => {
if (!topMessage) {
setUnreadCount(0);
return;
}
setLastMessageDate(topMessage.ts);
},
300,
[setUnreadCount, setLastMessageDate],
);
unreadMarkIndex.current = null;
return (
<MessageListProvider>
<SelectedMessagesProvider>
<VList
ref={virtualizerRef}
shift={isPrepend.current === true}
style={{ height: '100%' }}
aria-label={t('Message_list')}
aria-busy={isLoadingMoreMessages}
role='list'
className='messages-list'
keepMounted={keepMountedMessages}
onScroll={(offset: number) => {
handlePrepend(offset);
storeScrollPosition();
debouncedClearNewMessagesOnScroll();
const handle = virtualizerRef.current;
const viewportTopPadding = 21; // TODO: we should derive this value from somewhere else.
const topMessage = handle
? messages[handle.findItemIndex(handle.scrollOffset - viewportTopPadding) - (canPreview ? 1 : 0)]
: undefined;
handleTopVisibleMessage(topMessage);
handleDateScroll(topMessage, offset);
debouncedMessageRead();
}}
>
{canPreview ? (
<>
{hasMorePreviousMessages ? (
<div className='load-more' role='presentation'>
{isLoadingMoreMessages ? <LoadingMessagesIndicator /> : null}
</div>
) : (
<div role='listitem'>
<RoomForeword user={user} room={room} />
{retentionPolicy?.isActive ? <RetentionPolicyWarning room={room} /> : null}
</div>
)}
</>
) : null}
{messages.map((message, index, { [index - 1]: previous }) => {
const sequential = isMessageSequential(message, previous, messageGroupingPeriod);
const showUnreadDivider = firstUnreadMessageId === message._id;
const system = MessageTypes.isSystemMessage(message);
const visible = !isThreadMessage(message) && !system;
if (showUnreadDivider) {
unreadMarkIndex.current = index;
}
return (
<Fragment key={message._id}>
<MessageListItem
message={message}
previous={previous}
showUnreadDivider={showUnreadDivider}
showUserAvatar={showUserAvatar}
sequential={sequential}
visible={visible}
subscription={subscription}
system={system}
/>
</Fragment>
);
})}
{hasMoreNextMessages ? (
<div className='load-more' role='presentation'>
{isLoadingMoreMessages ? <LoadingMessagesIndicator /> : null}
</div>
) : null}
</VList>
</SelectedMessagesProvider>
</MessageListProvider>
);
};

View File

@@ -0,0 +1,39 @@
import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../contexts/RoomContext';
export type MessageListErrorBoundaryProps = { children: ReactNode };
const MessageListErrorBoundary = ({ children }: MessageListErrorBoundaryProps) => {
const { t } = useTranslation();
const room = useRoom();
return (
<ErrorBoundary
resetKeys={[room._id]}
fallback={
<States>
<StatesIcon name='circle-exclamation' variation='danger' />
<StatesTitle>{t('Error')}</StatesTitle>
<StatesSubtitle>{t('Error_something_went_wrong')}</StatesSubtitle>
<StatesActions>
<StatesAction
onClick={(): void => {
location.reload();
}}
>
<Icon name='reload' /> {t('Reload')}
</StatesAction>
</StatesActions>
</States>
}
>
{children}
</ErrorBoundary>
);
};
export default MessageListErrorBoundary;

View File

@@ -0,0 +1,100 @@
import { isThreadMessage, type IMessage, type ISubscription } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, Bubble, MessageDivider } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { isMessageNewDay } from './lib/isMessageNewDay';
import { useMessageListFormatDate } from '../../../components/message/list/MessageListContext';
import RoomMessage from '../../../components/message/variants/RoomMessage';
import SystemMessage from '../../../components/message/variants/SystemMessage';
import ThreadMessagePreview from '../../../components/message/variants/ThreadMessagePreview';
import { useDateRef } from '../providers/DateListProvider';
export type MessageListItemProps = {
message: IMessage;
previous?: IMessage;
showUnreadDivider: boolean;
sequential: boolean;
showUserAvatar: boolean;
visible: boolean;
subscription: ISubscription | undefined;
system: boolean;
};
export const MessageListItem = ({
message,
previous,
showUnreadDivider,
sequential,
showUserAvatar,
visible,
subscription,
system,
}: MessageListItemProps) => {
const { t } = useTranslation();
const formatDate = useMessageListFormatDate();
const ref = useDateRef();
const newDay = isMessageNewDay(message, previous);
const showDivider = newDay || showUnreadDivider;
const unread = Boolean(subscription?.tunread?.includes(message._id));
const mention = Boolean(subscription?.tunreadUser?.includes(message._id));
const all = Boolean(subscription?.tunreadGroup?.includes(message._id));
const ignoredUser = Boolean(subscription?.ignored?.includes(message.u._id));
const shouldShowAsSequential = sequential && !newDay;
return (
<>
{showDivider && (
<Box
ref={ref}
data-id={message.ts}
role='listitem'
className={css`
transition: opacity 0.2s ease-out;
`}
{...(newDay && {
'data-time': new Date(message.ts)
.toISOString()
.replaceAll(/[-T:.]/g, '')
.substring(0, 8),
})}
>
<MessageDivider unreadLabel={showUnreadDivider ? t('Unread_Messages').toLowerCase() : undefined}>
{newDay && (
<Bubble small secondary>
{formatDate(message.ts)}
</Bubble>
)}
</MessageDivider>
</Box>
)}
{visible && (
<RoomMessage
message={message}
showUserAvatar={showUserAvatar}
sequential={shouldShowAsSequential}
unread={unread}
mention={mention}
all={all}
ignoredUser={ignoredUser}
/>
)}
{isThreadMessage(message) && (
<div role='listitem'>
<ThreadMessagePreview
data-mid={message._id}
data-tmid={message.tmid}
data-unread={showUnreadDivider}
data-sequential={sequential}
sequential={shouldShowAsSequential}
message={message}
showUserAvatar={showUserAvatar}
/>
</div>
)}
{system && <SystemMessage showUserAvatar={showUserAvatar} message={message} />}
</>
);
};

View File

@@ -0,0 +1,12 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { createContext, useContext } from 'react';
type MessageHighlightContextValue = {
highlightMessageId?: IMessage['_id'];
};
const MessageHighlightContext = createContext<MessageHighlightContextValue>({});
export const useIsMessageHighlight = (_id: IMessage['_id']): boolean => useContext(MessageHighlightContext).highlightMessageId === _id;
export default MessageHighlightContext;

View File

@@ -0,0 +1,102 @@
import type { SyntheticEvent } from 'react';
import { createContext, useCallback, useContext, useEffect, useSyncExternalStore } from 'react';
import { selectedMessageStore } from '../../providers/SelectedMessagesProvider';
type SelectMessageContextValue = {
selectedMessageStore: typeof selectedMessageStore;
};
export const SelectedMessageContext = createContext({
selectedMessageStore,
} as SelectMessageContextValue);
export const useIsSelectedMessage = (mid: string, omit?: boolean): boolean => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
const subscribe = useCallback(
(callback: () => void): (() => void) => selectedMessageStore.on(mid, callback),
[selectedMessageStore, mid],
);
const getSnapshot = (): boolean => selectedMessageStore.isSelected(mid);
const isSelected = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {
if (isSelected || omit) {
return;
}
selectedMessageStore.addAvailableMessage(mid);
return () => selectedMessageStore.removeAvailableMessage(mid);
}, [mid, selectedMessageStore, isSelected, omit]);
return isSelected;
};
export const useIsSelecting = (): boolean => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
const subscribe = useCallback(
(callback: () => void): (() => void) => selectedMessageStore.on('toggleIsSelecting', callback),
[selectedMessageStore],
);
const getSnapshot = (): boolean => selectedMessageStore.getIsSelecting();
return useSyncExternalStore(subscribe, getSnapshot);
};
export const useToggleSelect = (mid: string): ((event?: SyntheticEvent<HTMLElement>) => void) => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
return useCallback(
(event) => {
event?.stopPropagation();
selectedMessageStore.toggle(mid);
},
[mid, selectedMessageStore],
);
};
export const useToggleSelectAll = (): (() => void) => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
return useCallback(() => {
selectedMessageStore.toggleAll(Array.from(selectedMessageStore.availableMessages));
}, [selectedMessageStore]);
};
export const useClearSelection = (): (() => void) => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
return useCallback(() => {
selectedMessageStore.clearStore();
}, [selectedMessageStore]);
};
export const useCountSelected = (): number => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
const subscribe = useCallback(
(callback: () => void): (() => void) => selectedMessageStore.on('change', callback),
[selectedMessageStore],
);
const getSnapshot = (): number => selectedMessageStore.count();
return useSyncExternalStore(subscribe, getSnapshot);
};
export const useAvailableMessagesCount = () => {
const { selectedMessageStore } = useContext(SelectedMessageContext);
const subscribe = useCallback(
(callback: () => void): (() => void) => selectedMessageStore.on('change', callback),
[selectedMessageStore],
);
const getSnapshot = () => selectedMessageStore.availableMessagesCount();
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useMessageListAutoLinkDomains } from '../../../../components/message/list/MessageListContext';
export const useAutoLinkDomains = (): string[] => {
const domains = useMessageListAutoLinkDomains();
const customDomains = useMemo(() => (domains ? domains.split(',').map((domain) => domain.trim()) : []), [domains]);
return useMemo(() => {
return customDomains;
}, [customDomains]);
};

View File

@@ -0,0 +1,42 @@
import type { IMessage, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useCallback, useMemo } from 'react';
import { AutoTranslate } from '../../../../lib/autotranslate';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../lib/autoTranslate';
import { isOwnUserMessage } from '../lib/isOwnUserMessage';
export type AutoTranslateOptions = {
autoTranslateEnabled: boolean;
autoTranslateLanguage?: string;
showAutoTranslate: (message: IMessage & Partial<ITranslatedMessage>) => boolean;
};
export const useAutoTranslate = (subscription?: ISubscription): AutoTranslateOptions => {
const autoTranslateSettingEnabled = useSetting('AutoTranslate_Enabled', false);
const isSubscriptionEnabled = autoTranslateSettingEnabled && subscription?.autoTranslateLanguage && subscription?.autoTranslate;
const isLivechatRoom = useMemo(() => subscription && roomCoordinator.isLivechatRoom(subscription?.t), [subscription]);
const autoTranslateEnabled = Boolean(isSubscriptionEnabled || isLivechatRoom);
const autoTranslateLanguage = autoTranslateEnabled && subscription ? AutoTranslate.getLanguage(subscription.rid) : undefined;
const showAutoTranslate = useCallback(
(message: IMessage): boolean => {
if (!autoTranslateEnabled || !autoTranslateLanguage) {
return false;
}
return (
!isOwnUserMessage(message, subscription) &&
!(message as { autoTranslateShowInverse?: boolean }).autoTranslateShowInverse &&
(hasTranslationLanguageInMessage(message, autoTranslateLanguage) ||
hasTranslationLanguageInAttachments(message.attachments, autoTranslateLanguage))
);
},
[subscription, autoTranslateEnabled, autoTranslateLanguage],
);
return useMemo(() => {
return { autoTranslateEnabled, autoTranslateLanguage, showAutoTranslate };
}, [autoTranslateEnabled, autoTranslateLanguage, showAutoTranslate]);
};

View File

@@ -0,0 +1,60 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useKatex } from './useKatex';
it('should return enabled true dollar syntax true and parenthesis syntax true if all settings is enabled', () => {
const { result } = renderHook(() => useKatex(), {
wrapper: mockAppRoot()
.withSetting('Katex_Enabled', true)
.withSetting('Katex_Dollar_Syntax', true)
.withSetting('Katex_Parenthesis_Syntax', true)
.build(),
});
expect(result.current.katexEnabled).toBe(true);
expect(result.current.katexDollarSyntaxEnabled).toBe(true);
expect(result.current.katexParenthesisSyntaxEnabled).toBe(true);
});
it('should return enabled false dollar syntax false and parenthesis syntax false if all settings is disabled', () => {
const { result } = renderHook(() => useKatex(), {
wrapper: mockAppRoot()
.withSetting('Katex_Enabled', false)
.withSetting('Katex_Dollar_Syntax', false)
.withSetting('Katex_Parenthesis_Syntax', false)
.build(),
});
expect(result.current.katexEnabled).toBe(false);
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
});
it('should return enabled true dollar syntax false and parenthesis syntax false if Katex_Enabled settings is enable', () => {
const { result } = renderHook(() => useKatex(), {
wrapper: mockAppRoot()
.withSetting('Katex_Enabled', true)
.withSetting('Katex_Dollar_Syntax', false)
.withSetting('Katex_Parenthesis_Syntax', false)
.build(),
});
expect(result.current.katexEnabled).toBe(true);
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
});
it('should return enabled false dollar syntax false and parenthesis syntax false if DollarSyntaxEnabled and ParenthesisSyntaxEnabled settings is enable', () => {
const { result } = renderHook(() => useKatex(), {
wrapper: mockAppRoot()
.withSetting('Katex_Enabled', false)
.withSetting('Katex_Dollar_Syntax', true)
.withSetting('Katex_Parenthesis_Syntax', true)
.build(),
});
expect(result.current.katexEnabled).toBe(false);
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
});

View File

@@ -0,0 +1,17 @@
import { useSetting } from '@rocket.chat/ui-contexts';
export const useKatex = (): {
katexEnabled: boolean;
katexDollarSyntaxEnabled: boolean;
katexParenthesisSyntaxEnabled: boolean;
} => {
const katexEnabled = useSetting('Katex_Enabled', true);
const katexDollarSyntaxEnabled = useSetting('Katex_Dollar_Syntax', false) && katexEnabled;
const katexParenthesisSyntaxEnabled = useSetting('Katex_Parenthesis_Syntax', true) && katexEnabled;
return {
katexEnabled,
katexDollarSyntaxEnabled,
katexParenthesisSyntaxEnabled,
};
};

View File

@@ -0,0 +1,61 @@
import { renderHook } from '@testing-library/react';
import { useKeepAtBottom } from './useKeepAtBottom';
let resizeCallbacks: ResizeObserverCallback[] = [];
class MockResizeObserver {
constructor(cb: ResizeObserverCallback) {
resizeCallbacks.push(cb);
}
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
// Rows are measured asynchronously after the list remounts (avatars, images, reactions),
// so the observer fires repeatedly while the restored position is being applied.
const settleList = (times: number) => {
for (let i = 0; i < times; i++) {
resizeCallbacks.forEach((cb) => cb([], {} as ResizeObserver));
}
};
const mountList = (isAtBottom: { current: boolean }) => {
const { result } = renderHook(() => useKeepAtBottom(isAtBottom));
const node = document.createElement('div');
node.appendChild(document.createElement('div'));
result.current.keepAtBottomRef(node);
const scrollToEnd = jest.fn();
result.current.setKeepAtBottom(scrollToEnd);
return scrollToEnd;
};
describe('useKeepAtBottom', () => {
beforeEach(() => {
resizeCallbacks = [];
(global as any).ResizeObserver = MockResizeObserver;
});
it('does not pull the list to the latest messages when the room was left mid-history', () => {
const scrollToEnd = mountList({ current: false });
settleList(3);
expect(scrollToEnd).not.toHaveBeenCalled();
});
it('keeps the list at the bottom when the room was left at the bottom', () => {
const scrollToEnd = mountList({ current: true });
settleList(3);
expect(scrollToEnd).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,58 @@
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import type { MutableRefObject } from 'react';
import { useCallback, useRef } from 'react';
// This hook is responsible for keeping the message list at the bottom despite any size changes to the container.
// Some examples of when this is needed are:
// - When the user is at the bottom and a new message arrives, the message list will grow and we want to keep it at the bottom. (This one is already handled in another place, but this hook also does this job)
// - When the user is at the bottom and the composer grows (e.g. when typing a long message), we want to keep it at the bottom.
// - When the user is at the bottom and the window is resized, the elements might reflow.
// - When the user is at the bottom and a thread opens, the horizontal size of the container will shrink, and the elements might reflow.
// - When the user is at the bottom and a message is reacted to, the message will grow and shift the list.
// - When the user is at the bottom and a video is loading, after loading the video element can change sizes and shift the list
export const useKeepAtBottom = (isAtBottom: MutableRefObject<boolean | null>) => {
const handleRef = useRef<(() => void) | null>(null);
const keepAtBottomRef = useSafeRefCallback(
useCallback(
(node: HTMLDivElement) => {
const sendToBottom = () => {
if (isAtBottom.current && handleRef.current) {
handleRef.current();
}
};
const listWrapper = node.firstChild;
const observer = new ResizeObserver(() => {
sendToBottom();
});
observer.observe(node);
if (listWrapper instanceof HTMLElement) {
observer.observe(listWrapper);
}
const sendToBottomEvent = (e: Event) => {
if (!(e.target instanceof HTMLElement)) return;
if (!e.target.closest('.rc-message-box')) return;
sendToBottom();
};
// Workaround for height hack in useAutoGrow
// node.style.height = '0';
// node.style.height = `${node.scrollHeight}px`;
window.addEventListener('input', sendToBottomEvent);
return () => {
observer.disconnect();
window.removeEventListener('input', sendToBottomEvent);
};
},
[isAtBottom],
),
);
const setKeepAtBottom = useCallback((handle: () => void | null) => {
handleRef.current = handle;
}, []);
return { keepAtBottomRef, setKeepAtBottom };
};

View File

@@ -0,0 +1,21 @@
import type { IMessage } from '@rocket.chat/core-typings';
export const useKeepMountedMessages = (messages: IMessage[], canPreview: boolean = false): number[] => {
const offset = canPreview ? 1 : 0;
return messages.reduce<number[]>((acc, message, index) => {
// Keep mounted anything with an embed that reloads when re-mounted: file
// attachments (audio/video players) and URL previews (e.g. YouTube iframes).
// Otherwise virtua recycles them on scroll-to-bottom (new message / reaction
// growing a message) and the iframe flickers.
//
// Don't extend this to other message/attachment types just to preserve some local
// UI state. Every match here stays mounted for the life of the list regardless of
// scroll position, so it only scales for the "needs a live browser resource" case
// above and adding more to it defeats virtualization in media-heavy channels.
const hasUrlPreview = message.urls?.some((url) => Object.keys(url.meta ?? {}).length > 0 || !!url.headers) ?? false;
if ((message.files?.length ?? 0) > 0 || hasUrlPreview) {
acc.push(index + offset);
}
return acc;
}, []);
};

View File

@@ -0,0 +1,48 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { Options, Root } from '@rocket.chat/message-parser';
import { useMemo } from 'react';
import { useAutoLinkDomains } from './useAutoLinkDomains';
import { useMessageListAutoTranslate } from '../../../../components/message/list/MessageListContext';
import { parseMessageTextToAstMarkdown } from '../../../../lib/parseMessageTextToAstMarkdown';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
export const useMessageBody = (message: IMessage | undefined): string | Root => {
const autoTranslateOptions = useMessageListAutoTranslate();
const customDomains = useAutoLinkDomains();
return useMemo(() => {
if (!message) {
return '';
}
if (message.md) {
const parseOptions: Options = {
customDomains,
emoticons: true,
};
const messageWithMd = parseMessageTextToAstMarkdown(message, parseOptions, autoTranslateOptions);
return messageWithMd.md;
}
if (message.msg) {
return toPlainTextRoot(message.msg);
}
if (message.attachments) {
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
if (attachment?.description) {
return attachment.description;
}
if (attachment?.title) {
return attachment.title;
}
}
return '';
}, [message, customDomains, autoTranslateOptions]);
};

View File

@@ -0,0 +1,40 @@
import type { IRoom, IMessage, MessageTypesValues } from '@rocket.chat/core-typings';
import { useStableArray } from '@rocket.chat/fuselage-hooks';
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useShallow } from 'zustand/shallow';
import { Messages } from '../../../../stores';
import { useRoom } from '../../contexts/RoomContext';
const mergeHideSysMessages = (
sysMesArray1: Array<MessageTypesValues>,
sysMesArray2: Array<MessageTypesValues>,
): Array<MessageTypesValues> => {
return Array.from(new Set([...sysMesArray1, ...sysMesArray2]));
};
export const useMessages = ({ rid }: { rid: IRoom['_id'] }): IMessage[] => {
const showThreadsInMainChannel = useUserPreference<boolean>('showThreadsInMainChannel', false);
const hideSysMesSetting = useSetting<MessageTypesValues[]>('Hide_System_Messages', []);
const room = useRoom();
const hideRoomSysMes: Array<MessageTypesValues> = Array.isArray(room.sysMes) ? room.sysMes : [];
const hideSysMessages = useStableArray(mergeHideSysMessages(hideSysMesSetting, hideRoomSysMes));
const predicate = useMemo(
() =>
createPredicateFromFilter<IMessage>({
rid,
_hidden: { $ne: true },
t: { $nin: hideSysMessages },
...(!showThreadsInMainChannel && {
$or: [{ tmid: { $exists: false } }, { tshow: { $eq: true } }],
}),
}),
[rid, hideSysMessages, showThreadsInMainChannel],
);
return Messages.use(useShallow((state) => state.filter(predicate).sort((a, b) => a.ts.getTime() - b.ts.getTime())));
};

View File

@@ -0,0 +1,71 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { withDebouncing } from '../../../../../lib/utils/highOrderFunctions';
import { sdk } from '../../../../lib/SDKClient';
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
import { Messages } from '../../../../stores';
const MAX_IDS_PER_REQUEST = 100;
const findParentMessage = (() => {
const waiting: string[] = [];
let resolve: (resolved: IMessage[] | PromiseLike<IMessage[]>) => void;
let pending = new Promise<IMessage[]>((r) => {
resolve = r;
});
const getMessages = withDebouncing({ wait: 500 })(async () => {
const messageIds = [...waiting];
waiting.length = 0;
const batches: string[][] = [];
for (let i = 0; i < messageIds.length; i += MAX_IDS_PER_REQUEST) {
batches.push(messageIds.slice(i, i + MAX_IDS_PER_REQUEST));
}
resolve(
Promise.all(
batches.map((ids) =>
sdk.rest.post('/v1/chat.getMessages', { messageIds: ids }).then(({ messages }) => messages.map((msg) => mapMessageFromApi(msg))),
),
).then((results) => results.flat()),
);
pending = new Promise<IMessage[]>((r) => {
resolve = r;
});
});
const get = async (tmid: IMessage['_id']) => {
void getMessages();
const messages = await pending;
const message = messages.find(({ _id }) => _id === tmid);
if (!message) {
throw new Error(`Message ${tmid} not found`);
}
return message;
};
return async (tmid: IMessage['_id']) => {
const message = Messages.state.get(tmid);
if (message) {
return message;
}
if (waiting.indexOf(tmid) === -1) {
waiting.push(tmid);
}
return get(tmid);
};
})();
export const useParentMessage = (mid: IMessage['_id']): UseQueryResult<IMessage> =>
useQuery({
queryKey: ['parent-message', { mid }],
queryFn: async () => findParentMessage(mid),
});

View File

@@ -0,0 +1,141 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import type { MutableRefObject } from 'react';
import type { WindowVirtualizerHandle } from 'virtua';
import useTryToJumpToMessage from './useTryToJumpToMessage';
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
import { RoomContext } from '../../contexts/RoomContext';
import { useGoToRoom } from '../../hooks/useGoToRoom';
jest.mock('../../../../lib/RoomHistoryManager', () => ({
RoomHistoryManager: {
getSurroundingChannelMessages: jest.fn().mockResolvedValue(undefined),
},
}));
jest.mock('../../hooks/useGoToRoom', () => ({
useGoToRoom: jest.fn(),
}));
jest.mock('../../../../providers/RouterProvider', () => ({
router: {
getSearchParameters: jest.fn().mockReturnValue({}),
navigate: jest.fn(),
},
}));
const mockedRoomHistoryManager = jest.mocked(RoomHistoryManager);
const mockedGoToRoom = jest.fn().mockResolvedValue(undefined);
const mockedSetIsJumpingToMessage = jest.fn();
beforeEach(() => {
jest.mocked(useGoToRoom).mockReturnValue(mockedGoToRoom);
mockedRoomHistoryManager.getSurroundingChannelMessages.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderJumpHook = (rid: string, message: unknown) =>
renderHook(
() =>
useTryToJumpToMessage({
rid,
virtualizerRef: { current: { scrollToIndex: jest.fn() } } as unknown as MutableRefObject<WindowVirtualizerHandle | null>,
setIsJumpingToMessage: mockedSetIsJumpingToMessage,
messages: [{ _id: 'another-msg' }],
}),
{
wrapper: mockAppRoot()
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1' }) })
.withEndpoint('GET', '/v1/chat.getMessage', (() => {
if (message instanceof Error) {
throw message;
}
return { message };
}) as any)
.wrap((children) => (
<RoomContext.Provider
value={
{
rid,
room: { _id: rid, t: 'c', name: 'general' },
hasMorePreviousMessages: false,
hasMoreNextMessages: false,
isLoadingMoreMessages: false,
} as any
}
>
{children}
</RoomContext.Provider>
))
.build(),
},
);
const message = {
_id: 'msg-1',
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
u: { _id: 'user-1', username: 'john' },
msg: 'Hello',
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
};
it('should navigate to the message room when the message belongs to another room', async () => {
renderJumpHook('room-1', { ...message, rid: 'room-2' });
await waitFor(() => expect(mockedGoToRoom).toHaveBeenCalledWith('room-2'));
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
});
it('should load surrounding messages in place when the message belongs to the current room', async () => {
renderJumpHook('room-1', { ...message, rid: 'room-1' });
await waitFor(() => expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalled());
expect(mockedGoToRoom).not.toHaveBeenCalled();
});
it('should discard the jump when the target message cannot be fetched', async () => {
renderJumpHook('room-1', new Error('error-invalid-message'));
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenCalledWith(false));
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
expect(mockedGoToRoom).not.toHaveBeenCalled();
});
it('should not navigate for a cross-room thread message, as it is handled by useTryToJumpToThreadMessage', async () => {
renderJumpHook('room-1', { ...message, rid: 'room-2', tmid: 'parent-msg-1', tshow: true });
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenCalledWith(true));
expect(mockedGoToRoom).not.toHaveBeenCalled();
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
});
it('should request surrounding messages only once while the target stays out of the list', async () => {
const { rerender } = renderJumpHook('room-1', { ...message, rid: 'room-1' });
await waitFor(() => expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1));
rerender();
rerender();
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1);
});
it('should stop jumping when the surrounding messages request fails', async () => {
mockedRoomHistoryManager.getSurroundingChannelMessages.mockRejectedValue(new Error('too-many-requests'));
const { rerender } = renderJumpHook('room-1', { ...message, rid: 'room-1' });
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenLastCalledWith(false));
rerender();
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,113 @@
import { isThreadMainMessage, isThreadMessage } from '@rocket.chat/core-typings';
import { useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { useEffect } from 'react';
import type { WindowVirtualizerHandle } from 'virtua';
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
import { messagesQueryKeys } from '../../../../lib/queryKeys';
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
import { setMessageJumpQueryStringParameter } from '../../../../lib/utils/setMessageJumpQueryStringParameter';
import { useRoomMessages } from '../../contexts/RoomContext';
import { useGoToRoom } from '../../hooks/useGoToRoom';
import { clearHighlightMessage, setHighlightMessage } from '../providers/messageHighlightSubscription';
type UseTryToJumpToMessageProps = {
rid: string;
virtualizerRef: MutableRefObject<WindowVirtualizerHandle | null>;
setIsJumpingToMessage: Dispatch<SetStateAction<boolean>>;
messages: { _id: string }[];
};
const useTryToJumpToMessage = ({ rid, virtualizerRef, setIsJumpingToMessage, messages }: UseTryToJumpToMessageProps) => {
const messageJumpParam = useSearchParameter('msg');
const { isLoadingMoreMessages } = useRoomMessages();
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
const goToRoom = useGoToRoom();
const { data: message, isError } = useQuery({
queryKey: messageJumpParam ? messagesQueryKeys.message(messageJumpParam) : [],
queryFn: async () => {
if (!messageJumpParam) return null;
const { message } = await getMessage({ msgId: messageJumpParam });
return mapMessageFromApi(message);
},
enabled: !!messageJumpParam,
});
const isThreadReply = !!message && isThreadMessage(message) && !isThreadMainMessage(message) && message.tshow !== true;
const targetId = isThreadReply ? message.tmid : messageJumpParam;
useEffect(() => {
if (!targetId || !message) {
return;
}
setIsJumpingToMessage(true);
if (message.rid !== rid) {
return;
}
void RoomHistoryManager.getSurroundingChannelMessages({ _id: targetId, rid })
.catch(() => undefined)
.finally(() => setIsJumpingToMessage(false));
}, [targetId, rid, message, setIsJumpingToMessage]);
useEffect(() => {
if (!messageJumpParam) {
setIsJumpingToMessage(false);
return;
}
if (isError) {
setIsJumpingToMessage(false);
setMessageJumpQueryStringParameter(null);
return;
}
if (!message) {
return;
}
if (!isThreadMessage(message) && !isThreadMainMessage(message) && message.rid !== rid) {
setIsJumpingToMessage(false);
goToRoom(message.rid);
return;
}
if (!virtualizerRef.current) {
return;
}
if (isLoadingMoreMessages || messages.length === 0) {
return;
}
const targetIndex = targetId ? messages.findIndex((current) => current._id === targetId) : -1;
if (!targetId || targetIndex < 0) {
return;
}
// TODO: Calculate the offset of the page, for the message to be in the center of the page
virtualizerRef.current?.scrollToIndex(targetIndex, {
align: 'center',
});
setHighlightMessage(targetId);
setTimeout(() => {
clearHighlightMessage();
}, 2000);
setTimeout(() => {
setIsJumpingToMessage(false);
if (targetId === messageJumpParam) {
setMessageJumpQueryStringParameter(null);
}
}, 500);
}, [messageJumpParam, virtualizerRef, setIsJumpingToMessage, rid, messages, message, isError, isLoadingMoreMessages, targetId, goToRoom]);
};
export default useTryToJumpToMessage;

View File

@@ -0,0 +1,130 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import useTryToJumpToThreadMessage from './useTryToJumpToThreadMessage';
import { RoomManager } from '../../../../lib/RoomManager';
import { useGoToRoom } from '../../hooks/useGoToRoom';
jest.mock('../../hooks/useGoToRoom', () => ({
useGoToRoom: jest.fn(),
}));
jest.mock('../../../../lib/RoomManager', () => ({
RoomManager: {
opened: undefined as string | undefined,
},
}));
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
openRouteLink: jest.fn(),
},
}));
jest.mock('../../../../providers/RouterProvider', () => ({
router: {
getSearchParameters: jest.fn().mockReturnValue({}),
getRouteParameters: jest.fn().mockReturnValue({}),
},
}));
jest.mock('../../../../stores', () => ({
Subscriptions: {
state: {
find: jest.fn().mockReturnValue(undefined),
},
},
}));
const mockedUseGoToRoom = jest.mocked(useGoToRoom);
const mockedGoToRoom = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
mockedUseGoToRoom.mockReturnValue(mockedGoToRoom);
});
afterEach(() => {
jest.clearAllMocks();
(RoomManager as { opened: string | undefined }).opened = undefined;
});
describe('useTryToJumpToThreadMessage', () => {
describe('early return when msg param is absent or jumpContext is jumpToUnread', () => {
it('should not navigate when msg search parameter is absent', () => {
const endpointSpy = jest.fn();
renderHook(() => useTryToJumpToThreadMessage(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/chat.getMessage', endpointSpy).build(),
});
expect(endpointSpy).not.toHaveBeenCalled();
expect(mockedGoToRoom).not.toHaveBeenCalled();
});
it('should not navigate when jumpContext is jumpToUnread', async () => {
const threadMessage = {
_id: 'msg-1',
rid: 'room-1',
tmid: 'parent-msg-1',
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
u: { _id: 'user-1', username: 'john' },
msg: 'Thread reply',
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
};
const endpointSpy = jest.fn().mockResolvedValue({ message: threadMessage });
renderHook(() => useTryToJumpToThreadMessage(), {
wrapper: mockAppRoot()
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1', jumpContext: 'jumpToUnread' }) })
.withEndpoint('GET', '/v1/chat.getMessage', endpointSpy)
.withMethod('getRoomById', () => ({ _id: 'room-1', t: 'c', name: 'general' }) as any)
.build(),
});
await waitFor(() => {
expect(endpointSpy).toHaveBeenCalledWith({ msgId: 'msg-1' });
});
await waitFor(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
expect(mockedGoToRoom).not.toHaveBeenCalled();
});
});
});
describe('when jumpContext is absent', () => {
it('should open the thread when jumpContext is null', async () => {
const threadMessage = {
_id: 'msg-1',
rid: 'room-1',
tmid: 'parent-msg-1',
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
u: { _id: 'user-1', username: 'john' },
msg: 'Thread reply',
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
};
const endpointSpy = jest.fn().mockResolvedValue({ message: threadMessage });
renderHook(() => useTryToJumpToThreadMessage(), {
wrapper: mockAppRoot()
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1' }) })
.withEndpoint('GET', '/v1/chat.getMessage', endpointSpy)
.withMethod('getRoomById', () => ({ _id: 'room-1', t: 'c', name: 'general' }) as any)
.build(),
});
await waitFor(() => {
expect(endpointSpy).toHaveBeenCalledWith({ msgId: 'msg-1' });
});
await waitFor(() => {
expect(mockedGoToRoom).toHaveBeenCalledWith('room-1', {
routeParamsOverrides: { tab: 'thread', context: 'parent-msg-1' },
replace: false,
});
});
});
});
});

View File

@@ -0,0 +1,55 @@
import { isThreadMainMessage, isThreadMessage } from '@rocket.chat/core-typings';
import { useEndpoint, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { RoomManager } from '../../../../lib/RoomManager';
import { messagesQueryKeys } from '../../../../lib/queryKeys';
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
import { useGoToRoom } from '../../hooks/useGoToRoom';
const useTryToJumpToThreadMessage = (): void => {
const messageJumpParam = useSearchParameter('msg');
const messageJumpContext = useSearchParameter('jumpContext');
const goToRoom = useGoToRoom();
const tab = useRouteParameter('tab');
const context = useRouteParameter('context');
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
const { data: message } = useQuery({
queryKey: messageJumpParam ? messagesQueryKeys.message(messageJumpParam) : [],
queryFn: async () => {
if (!messageJumpParam) return null;
const { message: raw } = await getMessage({ msgId: messageJumpParam });
return mapMessageFromApi(raw);
},
enabled: !!messageJumpParam,
});
useEffect(() => {
if (!messageJumpParam || messageJumpContext === 'jumpToUnread') {
return;
}
if (!message) {
return;
}
if (!isThreadMessage(message) && !isThreadMainMessage(message)) {
return;
}
if (tab === 'thread' && (context === message.tmid || context === message._id)) {
return;
}
(async () => {
await goToRoom(message.rid, {
routeParamsOverrides: { tab: 'thread', context: message.tmid || message._id },
replace: RoomManager.opened === message.rid,
});
})();
}, [messageJumpParam, message, goToRoom, tab, context, messageJumpContext]);
};
export default useTryToJumpToThreadMessage;

View File

@@ -0,0 +1 @@
export * from './MessageList';

View File

@@ -0,0 +1,37 @@
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from './autoTranslate';
describe('hasTranslationLanguageInMessage', () => {
const testCases = [
[{}, '', false],
[{ translations: { en: 'bah' } }, '', false],
[{ translations: { en: 'bah' } }, 'pt', false],
[{ translations: { en: 'bah' } }, 'en', true],
] as const;
testCases.forEach(([message, language, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(message)} with ${JSON.stringify(language)}`, () => {
const result = hasTranslationLanguageInMessage(message as unknown as IMessage, language);
expect(result).toBe(expectedResult);
});
});
});
describe('hasTranslationLanguageInAttachments', () => {
const testCases = [
[[{}], '', false],
[undefined, '', false],
[[{ translations: { en: 'bah' } }], '', false],
[[{ translations: { en: 'bah' } }], 'pt', false],
[[{ translations: { en: 'bah' } }], 'pt', false],
[[{ translations: { en: 'bah' } }], 'en', true],
] as const;
testCases.forEach(([attachment, language, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(attachment)} with ${JSON.stringify(language)}`, () => {
const result = hasTranslationLanguageInAttachments(attachment as unknown as MessageAttachment[], language);
expect(result).toBe(expectedResult);
});
});
});

View File

@@ -0,0 +1,8 @@
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';
import { isTranslatedMessageAttachment, isTranslatedMessage } from '@rocket.chat/core-typings';
export const hasTranslationLanguageInMessage = (message: IMessage, language: string): boolean =>
isTranslatedMessage(message) && Boolean(message.translations?.[language]);
export const hasTranslationLanguageInAttachments = (attachments: MessageAttachment[] = [], language: string): boolean =>
isTranslatedMessageAttachment(attachments) && attachments?.some((attachment) => attachment?.translations?.[language]);

View File

@@ -0,0 +1,59 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isMessageNewDay } from './isMessageNewDay';
const date = new Date('2021-10-27T00:00:00.000Z');
const baseMessage: IMessage = {
ts: date,
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message',
rid: 'roomId',
_id: 'messageId',
_updatedAt: date,
urls: [],
};
it('should return true if the message is from a different day', () => {
const message = {
...baseMessage,
};
const message2 = {
...baseMessage,
ts: new Date('2021-10-28T00:00:00.000Z'),
};
expect(isMessageNewDay(message, message2)).toBe(true);
});
it('should return false if the message is from the same day', () => {
const message = {
...baseMessage,
};
const message2 = {
...baseMessage,
};
expect(isMessageNewDay(message, message2)).toBe(false);
});
it('should return true if there is no previous message', () => {
const message = {
...baseMessage,
};
expect(isMessageNewDay(message, undefined)).toBe(true);
});
it('should return true for different days even if the range is on second', () => {
const previous: IMessage = {
...baseMessage,
ts: new Date(2022, 0, 1, 23, 59, 59, 999),
};
const current: IMessage = {
...baseMessage,
ts: new Date(2022, 0, 2, 0, 0, 0, 0),
};
expect(isMessageNewDay(current, previous)).toBe(true);
});

View File

@@ -0,0 +1,5 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isSameDay } from 'date-fns';
export const isMessageNewDay = (current: IMessage, previous: IMessage | undefined): boolean =>
!previous || !isSameDay(current.ts, previous.ts);

View File

@@ -0,0 +1,158 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isMessageSequential } from './isMessageSequential';
const TIME_RANGE_IN_SECONDS = 300;
const date = new Date('2021-10-27T00:00:00.000Z');
const baseMessage: IMessage = {
ts: date,
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message',
rid: 'roomId',
_id: 'messageId',
_updatedAt: date,
urls: [],
};
it('should return false if no previous message', () => {
const current: IMessage = {
...baseMessage,
};
expect(isMessageSequential(current, undefined, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it("should return false if both messages doesn't belong to the same user", () => {
const previous: IMessage = {
...baseMessage,
};
const current: IMessage = {
...baseMessage,
u: {
_id: 'userId2',
name: 'userName2',
username: 'userName2',
},
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return false if both messages belongs to the same user but have more than five minutes of difference', () => {
const previous: IMessage = {
...baseMessage,
};
const current: IMessage = {
...previous,
ts: new Date('2021-10-27T00:05:00.001Z'),
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return true if both messages belongs to the same user and have less than five minutes of difference', () => {
const previous: IMessage = {
...baseMessage,
};
const current: IMessage = {
...previous,
ts: new Date('2021-10-27T00:04:59.999Z'),
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
});
it('should return false if message are not groupable', () => {
const previous: IMessage = {
...baseMessage,
groupable: false,
};
const current: IMessage = {
...previous,
groupable: false,
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return false if both messages are not from the same thread', () => {
const previous: IMessage = {
...baseMessage,
tmid: 'threadId',
};
const current: IMessage = {
...previous,
tmid: 'threadId2',
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return true if both messages are from the same thread same user and bellow the time range', () => {
const previous: IMessage = {
...baseMessage,
tmid: 'threadId',
};
const current: IMessage = {
...previous,
tmid: 'threadId',
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
});
it('should return false if previous message is thread message but the current is a regular one', () => {
const previous: IMessage = {
tmid: 'threadId',
...baseMessage,
};
const current: IMessage = {
...baseMessage,
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return true if message is a reply from a previous message', () => {
const previous: IMessage = {
...baseMessage,
_id: 'threadId',
};
const current: IMessage = {
...previous,
tmid: 'threadId',
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
});
it("should return false if both messages don't have the same alias", () => {
const previous: IMessage = {
...baseMessage,
alias: 'alias',
};
const current: IMessage = {
...previous,
alias: 'alias2',
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return false if message is from system', () => {
const previous: IMessage = {
...baseMessage,
};
const current: IMessage = {
...previous,
ts: new Date('2021-10-27T00:04:59.999Z'),
t: 'au',
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});
it('should return false even if messages should be sequential, but they are from a different day', () => {
const previous: IMessage = {
...baseMessage,
ts: new Date(2022, 0, 1, 23, 59, 59, 999),
};
const current: IMessage = {
...baseMessage,
ts: new Date(2022, 0, 2, 0, 0, 0, 0),
};
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
});

View File

@@ -0,0 +1,36 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { MessageTypes } from '@rocket.chat/message-types';
import { differenceInSeconds } from 'date-fns';
import { isMessageNewDay } from './isMessageNewDay';
export 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.tmid) {
return [previous.tmid, previous._id].includes(current.tmid);
}
if (previous.tmid) {
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);
};

View File

@@ -0,0 +1,55 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { isOwnUserMessage } from './isOwnUserMessage';
const date = new Date('2021-10-27T00:00:00.000Z');
const baseMessage: IMessage = {
ts: date,
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message',
rid: 'roomId',
_id: 'messageId',
_updatedAt: date,
urls: [],
};
it('should return true if the message is from user', () => {
const message: IMessage = {
...baseMessage,
};
const subscription: ISubscription = {
u: {
_id: 'userId',
},
} as ISubscription;
expect(isOwnUserMessage(message, subscription)).toBe(true);
});
it('should return false if the message is not from user', () => {
const message: IMessage = {
...baseMessage,
};
const subscription: ISubscription = {
u: {
_id: 'otherUser',
},
} as ISubscription;
expect(isOwnUserMessage(message, subscription)).toBe(false);
});
it('should return false if there is no subscription', () => {
const message: IMessage = {
...baseMessage,
};
expect(isOwnUserMessage(message, undefined)).toBe(false);
});

View File

@@ -0,0 +1,3 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
export const isOwnUserMessage = (message: IMessage, subscription?: ISubscription): boolean => message.u._id === subscription?.u._id;

View File

@@ -0,0 +1,47 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { Root } from '@rocket.chat/message-parser';
import { isParsedMessage } from './isParsedMessage';
const date = new Date('2021-10-27T00:00:00.000Z');
const baseMessage: IMessage = {
ts: date,
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message',
md: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'message',
},
],
},
],
rid: 'roomId',
_id: 'messageId',
_updatedAt: date,
urls: [],
};
it('should return true if the message parsed', () => {
const message: IMessage = {
...baseMessage,
};
expect(isParsedMessage(message.md as Root)).toBe(true);
});
it('should return false if the message is not parsed', () => {
const message: IMessage = {
...baseMessage,
};
expect(isParsedMessage(message.msg as string)).toBe(false);
});

View File

@@ -0,0 +1,3 @@
import type { Root } from '@rocket.chat/message-parser';
export const isParsedMessage = (text: string | Root): text is Root => Array.isArray(text) && text.length > 0;

View File

@@ -0,0 +1,21 @@
import { isValidLink } from './isValidLink';
describe('isValidLink', () => {
const testCases = [
['/', false],
['test', false],
['test/test', false],
['.', false],
['./test', false],
['https://rocket.chat', true],
['rocket.chat', false],
['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAAsBAAEAAAICTAEAOw==', true],
] as const;
testCases.forEach(([parameter, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
const result = isValidLink(parameter);
expect(result).toBe(expectedResult);
});
});
});

View File

@@ -0,0 +1,7 @@
export const isValidLink = (link: string): boolean => {
try {
return Boolean(new URL(link));
} catch (error) {
return false;
}
};

View File

@@ -0,0 +1,22 @@
import type { ContextType, ReactNode } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import * as messageHighlightSubscription from './messageHighlightSubscription';
import MessageHighlightContext from '../contexts/MessageHighlightContext';
export type MessageHighlightProviderProps = { children: ReactNode };
const MessageHighlightProvider = ({ children }: MessageHighlightProviderProps) => {
const highlightMessageId = useSyncExternalStore(messageHighlightSubscription.subscribe, messageHighlightSubscription.getSnapshot);
const contextValue = useMemo<ContextType<typeof MessageHighlightContext>>(
() => ({
highlightMessageId,
}),
[highlightMessageId],
);
return <MessageHighlightContext.Provider value={contextValue}>{children}</MessageHighlightContext.Provider>;
};
export default MessageHighlightProvider;

View File

@@ -0,0 +1,162 @@
import { isThreadMainMessage, isRoomFederated } from '@rocket.chat/core-typings';
import { useLayout, useUser, useUserPreference, useSetting, useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useMemo, memo } from 'react';
import type { MessageListContextValue } from '../../../../components/message/list/MessageListContext';
import { MessageListContext } from '../../../../components/message/list/MessageListContext';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { useFormatTime } from '../../../../hooks/useFormatTime';
import { getRegexHighlight, getRegexHighlightUrl } from '../../../../lib/highlightWords';
import AttachmentProvider from '../../../../providers/AttachmentProvider';
import { useChat } from '../../contexts/ChatContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
import { useAutoTranslate } from '../hooks/useAutoTranslate';
import { useKatex } from '../hooks/useKatex';
export type MessageListProviderProps = {
children: ReactNode;
attachmentDimension?: {
width?: number;
height?: number;
};
};
const MessageListProvider = ({ children, attachmentDimension }: MessageListProviderProps) => {
const room = useRoom();
if (!room) {
throw new Error('Room not found');
}
const reactToMessage = useEndpoint('POST', '/v1/chat.react');
const user = useUser();
const uid = user?._id;
const username = user?.username;
const subscription = useRoomSubscription();
const { isMobile } = useLayout();
const federationReadReceipts = useSetting('Federation_Service_EDU_Process_Receipt', false);
const autoLinkDomains = useSetting('Message_CustomDomain_AutoLink', '');
const readReceiptsEnabled = useSetting('Message_Read_Receipt_Enabled', false) && (!isRoomFederated(room) || federationReadReceipts);
const readReceiptsStoreUsers = useSetting('Message_Read_Receipt_Store_Users', false);
const apiEmbedEnabled = useSetting('API_Embed', false);
const showRealName = useSetting('UI_Use_Real_Name', false);
const showColors = useSetting('HexColorPreview_Enabled', false);
const displayRolesGlobal = useSetting('UI_DisplayRoles', true);
const hideRolesPreference = Boolean(!useUserPreference<boolean>('hideRoles') && !isMobile);
const showRoles = displayRolesGlobal && hideRolesPreference;
const showUsername = Boolean(!useUserPreference<boolean>('hideUsernames') && !isMobile);
const highlights = useUserPreference<string[]>('highlights');
const { showAutoTranslate, autoTranslateLanguage, autoTranslateEnabled } = useAutoTranslate(subscription);
const { katexEnabled, katexDollarSyntaxEnabled, katexParenthesisSyntaxEnabled } = useKatex();
const formatDateAndTime = useFormatDateAndTime();
const formatTime = useFormatTime();
const formatDate = useFormatDate();
const hasSubscription = Boolean(subscription);
const msgParameter = useSearchParameter('msg');
const chat = useChat();
const context: MessageListContextValue = useMemo(
() => ({
showColors,
useUserHasReacted: username
? (message) =>
(reaction): boolean =>
Boolean(message.reactions?.[reaction]?.usernames.includes(username))
: () => (): boolean => false,
useShowFollowing: uid
? ({ message }): boolean => Boolean(message.replies && message.replies.indexOf(uid) > -1 && !isThreadMainMessage(message))
: (): boolean => false,
autoTranslate: {
autoTranslateEnabled,
autoTranslateLanguage,
showAutoTranslate,
},
useShowStarred: hasSubscription
? ({ message }): boolean => Boolean(Array.isArray(message.starred) && message.starred.find((star) => star._id === uid))
: (): boolean => false,
useMessageDateFormatter:
() =>
(date: Date): string =>
date.toLocaleString(),
apiEmbedEnabled,
autoLinkDomains,
showRoles,
showRealName,
showUsername,
jumpToMessageParam: msgParameter,
...(katexEnabled && {
katex: {
dollarSyntaxEnabled: katexDollarSyntaxEnabled,
parenthesisSyntaxEnabled: katexParenthesisSyntaxEnabled,
},
}),
highlights: highlights
?.map((str) => str.trim())
.map((highlight) => ({
highlight,
regex: getRegexHighlight(highlight),
urlRegex: getRegexHighlightUrl(highlight),
})),
useOpenEmojiPicker: uid
? (message) =>
(e): void => {
e.nativeEvent.stopImmediatePropagation();
chat?.emojiPicker.open(e.currentTarget, (emoji: string) => reactToMessage({ messageId: message._id, reaction: emoji }));
}
: () => (): void => undefined,
username,
readReceipts: {
enabled: readReceiptsEnabled,
storeUsers: readReceiptsStoreUsers,
},
formatDateAndTime,
formatTime,
formatDate,
}),
[
username,
uid,
showAutoTranslate,
autoTranslateEnabled,
hasSubscription,
autoTranslateLanguage,
showRoles,
showRealName,
showUsername,
katexEnabled,
katexDollarSyntaxEnabled,
katexParenthesisSyntaxEnabled,
highlights,
reactToMessage,
showColors,
msgParameter,
chat?.emojiPicker,
readReceiptsEnabled,
readReceiptsStoreUsers,
apiEmbedEnabled,
autoLinkDomains,
formatDateAndTime,
formatTime,
formatDate,
],
);
return (
<AttachmentProvider width={attachmentDimension?.width} height={attachmentDimension?.height}>
<MessageListContext.Provider value={context}>{children}</MessageListContext.Provider>
</AttachmentProvider>
);
};
export default memo(MessageListProvider);

View File

@@ -0,0 +1,45 @@
import type { IMessage } from '@rocket.chat/core-typings';
type SetHighlightFn = (_id: IMessage['_id']) => void;
type ClearHighlightFn = () => void;
type MessageHighlightSubscription = {
subscribe: (callback: () => void) => () => void;
getSnapshot: () => IMessage['_id'] | undefined;
setHighlight: SetHighlightFn;
clearHighlight: ClearHighlightFn;
};
const createMessageHighlightSubscription = (): MessageHighlightSubscription => {
let updateCb: () => void = () => undefined;
let highlightMessageId: IMessage['_id'] | undefined;
const subscribe = (cb: () => void): (() => void) => {
updateCb = cb;
return (): void => {
updateCb = (): void => undefined;
};
};
const getSnapshot = (): typeof highlightMessageId => highlightMessageId;
const setHighlight = (_id: IMessage['_id']): void => {
highlightMessageId = _id;
updateCb();
};
const clearHighlight = (): void => {
highlightMessageId = undefined;
updateCb();
};
return { subscribe, getSnapshot, setHighlight, clearHighlight };
};
export const {
getSnapshot,
subscribe,
setHighlight: setHighlightMessage,
clearHighlight: clearHighlightMessage,
} = createMessageHighlightSubscription();