snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
266
apps/meteor/client/views/room/MessageList/MessageList.spec.tsx
Normal file
266
apps/meteor/client/views/room/MessageList/MessageList.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
330
apps/meteor/client/views/room/MessageList/MessageList.tsx
Normal file
330
apps/meteor/client/views/room/MessageList/MessageList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
100
apps/meteor/client/views/room/MessageList/MessageListItem.tsx
Normal file
100
apps/meteor/client/views/room/MessageList/MessageListItem.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
17
apps/meteor/client/views/room/MessageList/hooks/useKatex.ts
Normal file
17
apps/meteor/client/views/room/MessageList/hooks/useKatex.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}, []);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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())));
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
1
apps/meteor/client/views/room/MessageList/index.ts
Normal file
1
apps/meteor/client/views/room/MessageList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './MessageList';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const isValidLink = (link: string): boolean => {
|
||||
try {
|
||||
return Boolean(new URL(link));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user