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:
41
apps/meteor/client/views/room/body/hooks/useDropTarget.ts
Normal file
41
apps/meteor/client/views/room/body/hooks/useDropTarget.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
49
apps/meteor/client/views/room/body/hooks/useFileUpload.ts
Normal file
49
apps/meteor/client/views/room/body/hooks/useFileUpload.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
141
apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
Normal file
141
apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
148
apps/meteor/client/views/room/body/hooks/useGetMore.ts
Normal file
148
apps/meteor/client/views/room/body/hooks/useGetMore.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
146
apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts
Normal file
146
apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user