snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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