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:
96
apps/meteor/client/views/room/body/DropTargetOverlay.tsx
Normal file
96
apps/meteor/client/views/room/body/DropTargetOverlay.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { DragEvent, ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
|
||||
|
||||
export type DropTargetOverlayProps = {
|
||||
enabled: boolean;
|
||||
reason?: ReactNode;
|
||||
onFileDrop?: (files: File[]) => void;
|
||||
visible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
function DropTargetOverlay({ enabled, reason, onFileDrop, visible = true, onDismiss }: DropTargetOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDragLeave = useStableCallback((event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
onDismiss?.();
|
||||
});
|
||||
|
||||
const handleDragOver = useStableCallback((event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = ['move', 'linkMove'].includes(event.dataTransfer.effectAllowed) ? 'move' : 'copy';
|
||||
});
|
||||
|
||||
const formatDateAndTime = useFormatDateAndTime();
|
||||
|
||||
const handleDrop = useStableCallback(async (event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
onDismiss?.();
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
|
||||
if (event.dataTransfer.types.includes('text/uri-list') && event.dataTransfer.types.includes('text/html')) {
|
||||
const fragment = document.createRange().createContextualFragment(event.dataTransfer.getData('text/html'));
|
||||
for await (const { src } of Array.from(fragment.querySelectorAll('img'))) {
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
const data = await response.blob();
|
||||
const extension = (await import('../../../../app/utils/lib/mimeTypes')).mime.extension(data.type);
|
||||
const filename = `File - ${formatDateAndTime(new Date())}.${extension}`;
|
||||
const file = new File([data], filename, { type: data.type });
|
||||
files.push(file);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFileDrop?.(files);
|
||||
});
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
role='dialog'
|
||||
data-qa='DropTargetOverlay'
|
||||
position='absolute'
|
||||
zIndex={1_000_000}
|
||||
inset={0}
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
fontScale='hero'
|
||||
textAlign='center'
|
||||
backgroundColor='surface-overlay'
|
||||
borderWidth={4}
|
||||
borderStyle='dashed'
|
||||
borderColor='currentColor'
|
||||
color={enabled ? 'default' : 'danger'}
|
||||
className={css`
|
||||
animation-name: zoom-in;
|
||||
animation-duration: 0.1s;
|
||||
`}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{enabled ? t('Drop_to_upload_file') : reason}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DropTargetOverlay);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Bubble } from '@rocket.chat/fuselage';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type JumpToRecentMessageButtonProps = {
|
||||
visible: boolean;
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const buttonStyle = css`
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
user-select: none;
|
||||
transform: translate(-50%, 0);
|
||||
|
||||
&.not {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.clicked {
|
||||
animation: fadeout 1s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
50% {
|
||||
visibility: visible;
|
||||
transform: translate(-50%, 150%);
|
||||
}
|
||||
100% {
|
||||
visibility: hidden;
|
||||
transform: translate(-50%, 150%);
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const JumpToRecentMessageButton = ({ visible, onClick, text }: JumpToRecentMessageButtonProps) => {
|
||||
const [clicked, setClicked] = useState(false);
|
||||
|
||||
return (
|
||||
<Box className={[buttonStyle, !visible && 'not', clicked && 'clicked'].filter(isTruthy)}>
|
||||
<Bubble
|
||||
icon='arrow-down'
|
||||
onClick={() => {
|
||||
onClick();
|
||||
setClicked(true);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Bubble>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default JumpToRecentMessageButton;
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingIndicator from '../../../components/LoadingIndicator';
|
||||
|
||||
const LoadingMessagesIndicator = () => <LoadingIndicator />;
|
||||
|
||||
export default LoadingMessagesIndicator;
|
||||
40
apps/meteor/client/views/room/body/MediaCallRoom.tsx
Normal file
40
apps/meteor/client/views/room/body/MediaCallRoom.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
|
||||
import type { PeerInfo } from '@rocket.chat/ui-voip';
|
||||
import { MediaCallRoomActivity, usePeekMediaSessionState, usePeekMediaSessionPeerInfo } from '@rocket.chat/ui-voip';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useRoom } from '../contexts/RoomContext';
|
||||
|
||||
const isMediaCallRoom = (room: IRoom, peerInfo?: PeerInfo) => {
|
||||
if (!peerInfo || 'number' in peerInfo) {
|
||||
return false;
|
||||
}
|
||||
if (!isDirectMessageRoom(room)) {
|
||||
return false;
|
||||
}
|
||||
if (room.uids?.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return room.uids.includes(peerInfo.userId);
|
||||
};
|
||||
|
||||
export type MediaCallRoomProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const MediaCallRoom = ({ children }: MediaCallRoomProps) => {
|
||||
const state = usePeekMediaSessionState();
|
||||
const peerInfo = usePeekMediaSessionPeerInfo();
|
||||
const room = useRoom();
|
||||
|
||||
if (state !== 'ongoing' || !isMediaCallRoom(room, peerInfo)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <MediaCallRoomActivity>{children}</MediaCallRoomActivity>;
|
||||
};
|
||||
|
||||
export default memo(MediaCallRoom);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RetentionPolicyWarning from './RetentionPolicyWarning';
|
||||
import { createRenteionPolicySettingsMock as createMock } from '../../../../tests/mocks/client/mockRetentionPolicySettings';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
|
||||
});
|
||||
|
||||
describe('RetentionPolicyWarning', () => {
|
||||
it('Should render callout if settings are valid', () => {
|
||||
const fakeRoom = createFakeRoom({ t: 'c' });
|
||||
render(<RetentionPolicyWarning room={fakeRoom} />, {
|
||||
wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000 }),
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('1 minute June 1, 2024 at 12:30 AM');
|
||||
});
|
||||
|
||||
it('Should not render callout if settings are invalid', () => {
|
||||
const fakeRoom = createFakeRoom({ t: 'c' });
|
||||
render(<RetentionPolicyWarning room={fakeRoom} />, {
|
||||
wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, advancedPrecisionCron: '* * * 12 *', advancedPrecision: true }),
|
||||
});
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Bubble } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { withErrorBoundary } from '../../../components/withErrorBoundary';
|
||||
import { usePruneWarningMessage } from '../../../hooks/usePruneWarningMessage';
|
||||
|
||||
export type RetentionPolicyWarningProps = { room: IRoom };
|
||||
|
||||
const RetentionPolicyWarning = ({ room }: RetentionPolicyWarningProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const message = usePruneWarningMessage(room);
|
||||
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' paddingInline={20} marginBlock={8}>
|
||||
<Bubble role='alert' aria-live='polite' aria-label={t('Retention_policy_warning_banner')} small secondary>
|
||||
{message}
|
||||
</Bubble>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default withErrorBoundary(RetentionPolicyWarning);
|
||||
254
apps/meteor/client/views/room/body/RoomBody.tsx
Normal file
254
apps/meteor/client/views/room/body/RoomBody.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { CustomVirtuaScrollbars, useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useMergedRefsV2 } from '../../../hooks/useMergedRefsV2';
|
||||
import { BubbleDate } from '../BubbleDate';
|
||||
import { MessageList } from '../MessageList';
|
||||
import DropTargetOverlay from './DropTargetOverlay';
|
||||
import JumpToRecentMessageButton from './JumpToRecentMessageButton';
|
||||
import UnreadMessagesIndicator from './UnreadMessagesIndicator';
|
||||
import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary';
|
||||
import RoomAnnouncement from '../RoomAnnouncement';
|
||||
import UploadProgressIndicator from './UploadProgress';
|
||||
import ComposerContainer from '../composer/ComposerContainer';
|
||||
import { useFileUpload } from './hooks/useFileUpload';
|
||||
import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved';
|
||||
import { useIsAtBottomRef } from './hooks/useIsAtBottomRef';
|
||||
import { useQuoteMessageByUrl } from './hooks/useQuoteMessageByUrl';
|
||||
import { useReadMessageWindowEvents } from './hooks/useReadMessageWindowEvents';
|
||||
import RoomComposer from '../composer/RoomComposer/RoomComposer';
|
||||
import { useChat } from '../contexts/ChatContext';
|
||||
import { useRoom, useRoomSubscription, useRoomMessages } from '../contexts/RoomContext';
|
||||
import { useDateScroll } from '../hooks/useDateScroll';
|
||||
import { useMessageListNavigation } from '../hooks/useMessageListNavigation';
|
||||
import { useRetentionPolicy } from '../hooks/useRetentionPolicy';
|
||||
import { useFileUploadDropTarget } from './hooks/useFileUploadDropTarget';
|
||||
import { useGetMore } from './hooks/useGetMore';
|
||||
import { useHasNewMessages } from './hooks/useHasNewMessages';
|
||||
import { useSelectAllAndScrollToTop } from './hooks/useSelectAllAndScrollToTop';
|
||||
import { useHandleUnread } from './hooks/useUnreadMessages';
|
||||
import { useKeepAtBottom } from '../MessageList/hooks/useKeepAtBottom';
|
||||
import useTryToJumpToThreadMessage from '../MessageList/hooks/useTryToJumpToThreadMessage';
|
||||
|
||||
const RoomBody = () => {
|
||||
const chat = useChat();
|
||||
if (!chat) {
|
||||
throw new Error('No ChatContext provided');
|
||||
}
|
||||
|
||||
const t = useTranslation();
|
||||
const isLayoutEmbedded = useEmbeddedLayout();
|
||||
const room = useRoom();
|
||||
const user = useUser();
|
||||
const toolbox = useRoomToolbox();
|
||||
const admin = useRole('admin');
|
||||
const subscription = useRoomSubscription();
|
||||
|
||||
const [shouldJumpToBottom, setShouldJumpToBottom] = useState<boolean>(false);
|
||||
const isAtBottom = useIsAtBottomRef(room._id);
|
||||
const [isJumpingToMessage, setIsJumpingToMessage] = useState<boolean>(false);
|
||||
|
||||
const retentionPolicy = useRetentionPolicy(room);
|
||||
|
||||
useTryToJumpToThreadMessage();
|
||||
|
||||
const hideFlexTab = useUserPreference<boolean>('hideFlexTab') || undefined;
|
||||
const hideUsernames = useUserPreference<boolean>('hideUsernames');
|
||||
const displayAvatars = useUserPreference<boolean>('displayAvatars');
|
||||
|
||||
const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useRoomMessages();
|
||||
|
||||
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', false);
|
||||
|
||||
const canPreviewChannelRoom = usePermission('preview-c-room');
|
||||
|
||||
const subscribed = !!subscription;
|
||||
|
||||
const canPreview = useMemo(() => {
|
||||
if (room && room.t !== 'c') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowAnonymousRead === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canPreviewChannelRoom) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return subscribed;
|
||||
}, [allowAnonymousRead, canPreviewChannelRoom, room, subscribed]);
|
||||
|
||||
const {
|
||||
handleUnreadBarJumpToButtonClick,
|
||||
handleMarkAsReadButtonClick,
|
||||
counter: [unread],
|
||||
setUnreadCount,
|
||||
setLastMessageDate,
|
||||
debouncedMessageRead,
|
||||
} = useHandleUnread(room, subscription);
|
||||
|
||||
const { handleDateScroll, bubbleRef, listStyle, ...bubbleDate } = useDateScroll();
|
||||
|
||||
const { innerRef: getMoreInnerRef } = useGetMore(room._id, isJumpingToMessage);
|
||||
|
||||
const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget();
|
||||
const { uploads, isUploading } = useFileUpload();
|
||||
|
||||
const { messageListRef } = useMessageListNavigation();
|
||||
const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop();
|
||||
|
||||
const {
|
||||
handleNewMessageButtonClick,
|
||||
handleJumpToRecentButtonClick,
|
||||
handleComposerResize,
|
||||
hasNewMessages,
|
||||
debouncedClearNewMessagesOnScroll,
|
||||
} = useHasNewMessages(room._id, user?._id, setShouldJumpToBottom, isAtBottom);
|
||||
|
||||
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
|
||||
|
||||
const innerRef = useMergedRefsV2(getMoreInnerRef, selectAndScrollRef, messageListRef, keepAtBottomRef);
|
||||
|
||||
const handleNavigateToPreviousMessage = useCallback((): void => {
|
||||
chat.messageEditing.toPreviousMessage();
|
||||
}, [chat.messageEditing]);
|
||||
|
||||
const handleNavigateToNextMessage = useCallback((): void => {
|
||||
chat.messageEditing.toNextMessage();
|
||||
}, [chat.messageEditing]);
|
||||
|
||||
const handleCloseFlexTab = useCallback(
|
||||
(e: MouseEvent<HTMLElement>): void => {
|
||||
/*
|
||||
* check if the element is a button or anchor
|
||||
* it considers the role as well
|
||||
* usually, the flex tab is closed when clicking outside of it
|
||||
* but if the user clicks on a button or anchor, we don't want to close the flex tab
|
||||
* because the user could be actually trying to open the flex tab through those elements
|
||||
*/
|
||||
|
||||
const checkElement = (element: HTMLElement | null): boolean => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
if (element instanceof HTMLButtonElement || element.getAttribute('role') === 'button') {
|
||||
return true;
|
||||
}
|
||||
if (element instanceof HTMLAnchorElement || element.getAttribute('role') === 'link') {
|
||||
return true;
|
||||
}
|
||||
return checkElement(element.parentElement);
|
||||
};
|
||||
|
||||
if (checkElement(e.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toolbox.closeTab();
|
||||
},
|
||||
[toolbox],
|
||||
);
|
||||
|
||||
useGoToHomeOnRemoved(room, user?._id);
|
||||
useReadMessageWindowEvents();
|
||||
useQuoteMessageByUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLayoutEmbedded && room.announcement && <RoomAnnouncement announcement={room.announcement} />}
|
||||
<Box key={room._id} className={['main-content-flex', listStyle]}>
|
||||
<section
|
||||
role='presentation'
|
||||
className={`messages-container flex-tab-main-content ${admin ? 'admin' : ''}`}
|
||||
id={`chat-window-${room._id}`}
|
||||
onClick={hideFlexTab && handleCloseFlexTab}
|
||||
>
|
||||
<div className='messages-container-wrapper'>
|
||||
<div className='messages-container-main' {...fileUploadTriggerProps}>
|
||||
<DropTargetOverlay {...fileUploadOverlayProps} />
|
||||
<Box position='absolute' width='full'>
|
||||
{isUploading && <UploadProgressIndicator uploads={uploads} />}
|
||||
{Boolean(unread) && (
|
||||
<UnreadMessagesIndicator
|
||||
count={unread}
|
||||
onJumpButtonClick={handleUnreadBarJumpToButtonClick}
|
||||
onMarkAsReadButtonClick={handleMarkAsReadButtonClick}
|
||||
/>
|
||||
)}
|
||||
<BubbleDate ref={bubbleRef} {...bubbleDate} />
|
||||
</Box>
|
||||
<div className={['messages-box'].filter(isTruthy).join(' ')}>
|
||||
<JumpToRecentMessageButton visible={hasNewMessages} onClick={handleNewMessageButtonClick} text={t('New_messages')} />
|
||||
<JumpToRecentMessageButton
|
||||
visible={hasMoreNextMessages}
|
||||
onClick={handleJumpToRecentButtonClick}
|
||||
text={t('Jump_to_recent_messages')}
|
||||
/>
|
||||
{!canPreview ? (
|
||||
<div className='content room-not-found error-color'>
|
||||
<div>{t('You_must_join_to_view_messages_in_this_channel')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={[
|
||||
'wrapper',
|
||||
hasMoreNextMessages && 'has-more-next',
|
||||
hideUsernames && 'hide-usernames',
|
||||
!displayAvatars && 'hide-avatar',
|
||||
]
|
||||
.filter(isTruthy)
|
||||
.join(' ')}
|
||||
>
|
||||
<MessageListErrorBoundary>
|
||||
<CustomVirtuaScrollbars ref={innerRef} key={room._id}>
|
||||
<MessageList
|
||||
rid={room._id}
|
||||
shouldJumpToBottom={shouldJumpToBottom}
|
||||
setShouldJumpToBottom={setShouldJumpToBottom}
|
||||
isAtBottom={isAtBottom}
|
||||
isJumpingToMessage={isJumpingToMessage}
|
||||
setIsJumpingToMessage={setIsJumpingToMessage}
|
||||
canPreview={canPreview}
|
||||
hasMorePreviousMessages={hasMorePreviousMessages}
|
||||
isLoadingMoreMessages={isLoadingMoreMessages}
|
||||
user={user}
|
||||
room={room}
|
||||
retentionPolicy={retentionPolicy}
|
||||
hasMoreNextMessages={hasMoreNextMessages}
|
||||
setUnreadCount={setUnreadCount}
|
||||
setLastMessageDate={setLastMessageDate}
|
||||
debouncedClearNewMessagesOnScroll={debouncedClearNewMessagesOnScroll}
|
||||
handleDateScroll={handleDateScroll}
|
||||
debouncedMessageRead={debouncedMessageRead}
|
||||
setKeepAtBottom={setKeepAtBottom}
|
||||
/>
|
||||
</CustomVirtuaScrollbars>
|
||||
</MessageListErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
<RoomComposer aria-label={t('Room_composer')}>
|
||||
<ComposerContainer
|
||||
subscription={subscription}
|
||||
onResize={handleComposerResize}
|
||||
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
|
||||
onNavigateToNextMessage={handleNavigateToNextMessage}
|
||||
onClickSelectAll={selectAllAndScrollToTop}
|
||||
// TODO: send previewUrls param
|
||||
// previewUrls={}
|
||||
/>
|
||||
</RoomComposer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomBody);
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
|
||||
import { FlexItem, Box } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RoomForewordUsernameList from './RoomForewordUsernameList';
|
||||
|
||||
export type RoomForewordProps = { user: IUser | null; room: IRoom };
|
||||
|
||||
const RoomForeword = ({ user, room }: RoomForewordProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isDirectMessageRoom(room)) {
|
||||
return (
|
||||
<Box fontScale='c1' color='default' display='flex' justifyContent='center' marginBlock={8}>
|
||||
{t('Start_of_conversation')}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const usernames = room.usernames?.filter((username) => username !== user?.username);
|
||||
|
||||
if (!usernames || usernames.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box is='div' flexGrow={1} display='flex' justifyContent='center' flexDirection='column' marginBlock={8}>
|
||||
<FlexItem grow={1}>
|
||||
<Box display='flex' alignItems='center' justifyContent='center'>
|
||||
{usernames.map((username, index) => (
|
||||
<Box is='span' marginInline={4} key={index}>
|
||||
<UserAvatar size='x48' username={username} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</FlexItem>
|
||||
<Box display='flex' color='default' fontScale='h4' flexGrow={1} justifyContent='center' marginBlock={16}>
|
||||
{t('Direct_message_you_have_joined')}
|
||||
</Box>
|
||||
<Box is='div' flexGrow={1} display='flex' justifyContent='center'>
|
||||
<RoomForewordUsernameList usernames={usernames} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomForeword;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Margins } from '@rocket.chat/fuselage';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import RoomForewordUsernameListItem from './RoomForewordUsernameListItem';
|
||||
|
||||
export type RoomForewordUsernameListProps = { usernames: Array<NonNullable<IUser['username']>> };
|
||||
|
||||
const RoomForewordUsernameList = ({ usernames }: RoomForewordUsernameListProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Margins inline={4}>
|
||||
{usernames.map((username) => (
|
||||
<RoomForewordUsernameListItem
|
||||
username={username}
|
||||
key={username}
|
||||
href={router.getRoomRoute('d', { name: username }).path || undefined}
|
||||
/>
|
||||
))}
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomForewordUsernameList;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Icon, Tag, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useUserDisplayName } from '@rocket.chat/ui-client';
|
||||
|
||||
import { useUserInfoQuery } from '../../../../hooks/useUserInfoQuery';
|
||||
|
||||
export type RoomForewordUsernameListItemProps = {
|
||||
href: string | undefined;
|
||||
username: NonNullable<IUser['username']>;
|
||||
};
|
||||
|
||||
const RoomForewordUsernameListItem = ({ username, href }: RoomForewordUsernameListItemProps) => {
|
||||
const { data, isLoading, isError, isSuccess } = useUserInfoQuery({ username });
|
||||
const displayName = useUserDisplayName({ name: data?.user?.name, username });
|
||||
|
||||
return (
|
||||
<Tag icon={<Icon name='user' size='x20' />} data-username={username} large href={href}>
|
||||
{isLoading && <Skeleton variant='rect' />}
|
||||
{isError && username}
|
||||
{isSuccess && displayName}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomForewordUsernameListItem;
|
||||
56
apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx
Normal file
56
apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import RoomInvite from './RoomInviteBody';
|
||||
import * as stories from './RoomInviteBody.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
describe('RoomInvite', () => {
|
||||
const onAccept = jest.fn();
|
||||
const onReject = jest.fn();
|
||||
const inviter = {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should call onAccept when accept button is clicked', async () => {
|
||||
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalled();
|
||||
expect(onReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onReject when reject button is clicked', async () => {
|
||||
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reject' }));
|
||||
|
||||
expect(onReject).toHaveBeenCalled();
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import RoomInvite from './RoomInviteBody';
|
||||
|
||||
const meta = {
|
||||
component: RoomInvite,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onAccept: action('onAccept'),
|
||||
onReject: action('onReject'),
|
||||
},
|
||||
} satisfies Meta<typeof RoomInvite>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInfoLink: Story = {
|
||||
args: {
|
||||
infoLink: {
|
||||
label: 'Learn more',
|
||||
href: 'https://rocket.chat',
|
||||
},
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
46
apps/meteor/client/views/room/body/RoomInviteBody.tsx
Normal file
46
apps/meteor/client/views/room/body/RoomInviteBody.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { IInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, Chip, States, StatesActions, StatesIcon, StatesLink, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type RoomInviteBodyProps = {
|
||||
isLoading?: boolean;
|
||||
inviter: IInviteSubscription['inviter'];
|
||||
infoLink?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
const RoomInviteBody = ({ inviter, infoLink, isLoading, onAccept, onReject }: RoomInviteBodyProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { name, username } = inviter;
|
||||
|
||||
return (
|
||||
<Box margin='auto'>
|
||||
<States>
|
||||
<StatesIcon name='mail' />
|
||||
<StatesTitle>{t('Message_request')}</StatesTitle>
|
||||
<StatesSubtitle>
|
||||
<Box marginBlockEnd={8}>{t('You_have_been_invited_to_have_a_conversation_with')}</Box>
|
||||
<Chip>
|
||||
<UserAvatar username={username} size='x16' /> {name || username}
|
||||
</Chip>
|
||||
</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<Button secondary danger loading={isLoading} onClick={onReject}>
|
||||
{t('Reject')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onClick={onAccept}>
|
||||
{t('Accept')}
|
||||
</Button>
|
||||
</StatesActions>
|
||||
{infoLink && <StatesLink href={infoLink.href}>{infoLink.label}</StatesLink>}
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteBody;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Bubble } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type UnreadMessagesIndicatorProps = {
|
||||
count: number;
|
||||
onJumpButtonClick: () => void;
|
||||
onMarkAsReadButtonClick: () => void;
|
||||
};
|
||||
|
||||
const indicatorStyle = css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const UnreadMessagesIndicator = ({ count, onJumpButtonClick, onMarkAsReadButtonClick }: UnreadMessagesIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box className={indicatorStyle} marginBlockStart={8}>
|
||||
<Bubble
|
||||
onClick={onJumpButtonClick}
|
||||
onDismiss={onMarkAsReadButtonClick}
|
||||
icon='arrow-up'
|
||||
dismissProps={{ 'title': t('Mark_as_read'), 'aria-label': `${t('Mark_as_read')}` }}
|
||||
>
|
||||
{t('unread_messages_counter', { count })}
|
||||
</Bubble>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnreadMessagesIndicator;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Bubble } from '@rocket.chat/fuselage';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Upload } from '../../../../lib/chats/Upload';
|
||||
|
||||
export type UploadProgressIndicatorProps = {
|
||||
uploads: readonly Upload[];
|
||||
};
|
||||
|
||||
const UploadProgressIndicator = ({ uploads }: UploadProgressIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { percentage, count } = useMemo(() => {
|
||||
const validUploads = uploads.filter((upload) => !upload.error);
|
||||
const activeUploads = validUploads.filter((upload) => upload.percentage < 100);
|
||||
|
||||
if (activeUploads.length === 0) {
|
||||
return { percentage: 0, count: 0 };
|
||||
}
|
||||
|
||||
const totalPercentage = validUploads.reduce((sum, upload) => sum + upload.percentage, 0);
|
||||
const avgPercentage = Math.round(totalPercentage / validUploads.length);
|
||||
|
||||
return {
|
||||
percentage: avgPercentage,
|
||||
count: activeUploads.length,
|
||||
};
|
||||
}, [uploads]);
|
||||
|
||||
const customClass = useMemo(
|
||||
() => css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
|
||||
& .rcx-bubble__item {
|
||||
position: relative;
|
||||
|
||||
> span {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: ${percentage}%;
|
||||
height: 100%;
|
||||
transition: width 0.3s ease-out;
|
||||
background-color: var(--rcx-color-button-background-primary-press, #10529e);
|
||||
}
|
||||
}
|
||||
`,
|
||||
[percentage],
|
||||
);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={customClass} marginBlockStart={8}>
|
||||
<Bubble role='status'>{`${percentage}% ${t('Uploading__count__file', { count })}`}</Bubble>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadProgressIndicator;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './UploadProgressIndicator';
|
||||
@@ -0,0 +1,273 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInvite renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt="Avatar"
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RoomInvite renders Loading without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt="Avatar"
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--loading rcx-button--secondary-danger rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--loading rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RoomInvite renders WithInfoLink without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt="Avatar"
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
class="rcx-box rcx-box--full rcx-states__link"
|
||||
href="https://rocket.chat"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
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