Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/meteor/client/components/avatar/RoomAvatarEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';

Expand All @@ -20,7 +19,7 @@ type RoomAvatarEditorProps = {
onChangeAvatar: (url: string | null) => void;
};

const RoomAvatarEditor = ({ disabled = false, room, roomAvatar, onChangeAvatar }: RoomAvatarEditorProps): ReactElement<any> => {
const RoomAvatarEditor = ({ disabled = false, room, roomAvatar, onChangeAvatar }: RoomAvatarEditorProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Box, Button, Avatar, IconButton } from '@rocket.chat/fuselage';
import { Field, FieldLabel, FieldRow, FieldError, TextInput } from '@rocket.chat/fuselage-forms';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement, ChangeEvent } from 'react';
import type { ChangeEvent } from 'react';
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

Expand All @@ -23,7 +23,7 @@ type UserAvatarEditorProps = {
name: IUser['name'];
};

function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disabled, etag }: UserAvatarEditorProps): ReactElement<any> {
function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disabled, etag }: UserAvatarEditorProps) {
const { t } = useTranslation();
const useFullNameForDefaultAvatar = useSetting('UI_Use_Name_Avatar');
const rotateImages = useSetting('FileUpload_RotateImages');
Expand Down
6 changes: 4 additions & 2 deletions apps/meteor/client/components/message/MessageCollapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactNode } from 'react';

import AttachmentDownload from './content/attachments/structure/AttachmentDownload';
import AttachmentSize from './content/attachments/structure/AttachmentSize';
import CollapsibleContent from './content/collapsible/CollapsibleContent';
import { useCollapse } from './hooks/useCollapse';

type MessageCollapsibleProps = {
Expand All @@ -15,15 +16,16 @@ type MessageCollapsibleProps = {
};

const MessageCollapsible = ({ children, title, hasDownload, link, size, isCollapsed }: MessageCollapsibleProps) => {
const [collapsed, collapse] = useCollapse(isCollapsed);
const [collapsed, toggleCollapse] = useCollapse(isCollapsed);

return (
<>
<Box display='flex' flexDirection='row' color='hint' fontScale='c1' alignItems='center'>
<Box withTruncatedText title={title}>
{title}
</Box>
{size && <AttachmentSize size={size} />} {collapse}
{size && <AttachmentSize size={size} />}{' '}
<CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
{hasDownload && link && <AttachmentDownload title={title} href={link} />}
</Box>
{!collapsed && children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import AttachmentThumb from './structure/AttachmentThumb';
import AttachmentTitle from './structure/AttachmentTitle';
import MarkdownText from '../../../MarkdownText';
import { useCollapse } from '../../hooks/useCollapse';
import CollapsibleContent from '../collapsible/CollapsibleContent';

const applyMarkdownIfRequires = (
list: MessageAttachmentDefault['mrkdwn_in'] = ['text', 'pretext'],
Expand All @@ -27,7 +28,7 @@ const applyMarkdownIfRequires = (
type DefaultAttachmentProps = MessageAttachmentDefault;

const DefaultAttachment = (attachment: DefaultAttachmentProps) => {
const [collapsed, collapse] = useCollapse(!!attachment.collapsed);
const [collapsed, toggleCollapse] = useCollapse(!!attachment.collapsed);

return (
<AttachmentBlock
Expand Down Expand Up @@ -66,7 +67,7 @@ const DefaultAttachment = (attachment: DefaultAttachmentProps) => {
>
{attachment.title}
</AttachmentTitle>{' '}
{collapse}
<CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
</AttachmentRow>
)}
{!collapsed && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import { useTranslation } from 'react-i18next';
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
import UrlPreviewResolver from './UrlPreviewResolver';
import { useCollapse } from '../../hooks/useCollapse';
import CollapsibleContent from '../collapsible/CollapsibleContent';

const UrlPreview = (props: UrlPreviewMetadata) => {
const autoLoadMedia = useAttachmentAutoLoadEmbedMedia();
const [collapsed, collapse] = useCollapse(!autoLoadMedia);
const [collapsed, toggleCollapse] = useCollapse(!autoLoadMedia);
const { t } = useTranslation();

return (
<>
<Box display='flex' flexDirection='row' color='hint' fontScale='c1' alignItems='center'>
{t('Link_Preview')} {collapse}
{t('Link_Preview')} <CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
</Box>
{!collapsed && <UrlPreviewResolver {...props} />}
</>
Expand Down
9 changes: 9 additions & 0 deletions apps/meteor/client/components/message/hooks/useCollapse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { useAttachmentIsCollapsedByDefault } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';

export const useCollapse = (attachmentCollapsed?: boolean) => {
const collapseByDefault = useAttachmentIsCollapsedByDefault();
const [collapsed, toggleCollapsed] = useToggle(collapseByDefault || attachmentCollapsed);
return [collapsed, useCallback(() => toggleCollapsed(), [toggleCollapsed])] as const;
};
11 changes: 0 additions & 11 deletions apps/meteor/client/components/message/hooks/useCollapse.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const useMarkAsUnreadMutation = () => {
roomId: string;
subscription: ISubscription;
}) => {
await LegacyRoomManager.close(subscription.t + subscription.name);
LegacyRoomManager.close(subscription.t + subscription.name);
if ('message' in props) {
const { message } = props;
await unreadMessages({ firstUnreadMessage: { _id: message._id } });
Expand Down
223 changes: 223 additions & 0 deletions apps/meteor/client/hooks/useIdleDetection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { faker } from '@faker-js/faker';
import { renderHook, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react';

import { useIdleDetection, DEFAULT_IDLE_DETECTION_OPTIONS } from './useIdleDetection';

describe('useIdleDetection', () => {
const idleCallback = jest.fn<void, [event: Event]>();
const activeCallback = jest.fn<void, [event: Event]>();
const changeCallback = jest.fn<void, [event: Event]>();

// userEvent does not trigger when using `jest.useFakeTimers()`
// because userEvent relies on timers to trigger events
// Setting delay to null ensures the interaction is triggered immediately
// removing this dependency
const user = userEvent.setup({ delay: null });

beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

describe.each([
...[60_000, 30_000, 300_000, 3_000_000].flatMap((time) =>
[DEFAULT_IDLE_DETECTION_OPTIONS.id, faker.string.uuid()].flatMap((id) =>
[false, true].flatMap((awayOnWindowBlur) => ({
...DEFAULT_IDLE_DETECTION_OPTIONS,
id,
awayOnWindowBlur,
time,
})),
),
),
])('time: $time, id: $id, awayOnWindowBlur: $awayOnWindowBlur', (args) => {
const eventId = args.id;
const idleDelayMillis = args.time;

let cleanupEvents: () => void;

beforeAll(() => {
document.addEventListener(`${eventId}_idle`, idleCallback);
document.addEventListener(`${eventId}_active`, activeCallback);
document.addEventListener(`${eventId}_change`, changeCallback);

cleanupEvents = () => {
document.removeEventListener(`${eventId}_idle`, idleCallback);
document.removeEventListener(`${eventId}_active`, activeCallback);
document.removeEventListener(`${eventId}_change`, changeCallback);
};
});

afterAll(() => {
cleanupEvents();
});

beforeEach(() => {
jest.clearAllMocks();
});

it('should not dispatch any event on initial render', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});

it('should dispatch idle event if no interaction before timeout', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});

expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);

expect(activeCallback).toHaveBeenCalledTimes(0);

expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});

if (args.awayOnWindowBlur) {
it('should dispatch idle event on window blur', async () => {
renderHook(() => useIdleDetection(args));

expect(activeCallback).toHaveBeenCalledTimes(0);
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

fireEvent.blur(window);

expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);

expect(activeCallback).toHaveBeenCalledTimes(0);

expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});
} else {
it('should not dispatch idle event on window blur', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

fireEvent.blur(window);

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
}

it('should dispatch active event if idle after interaction', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

// Ensure the idle event is dispatched
act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});

expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);

expect(activeCallback).toHaveBeenCalledTimes(0);

expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);

await user.click(document.body);

expect(idleCallback).toHaveBeenCalledTimes(1);

expect(activeCallback).toHaveBeenCalledTimes(1);
expect(activeCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(activeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_active`);

expect(changeCallback).toHaveBeenCalledTimes(2);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});

it('should not dispatch any event if active and an interaction happened before timeout', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

const halfTime = idleDelayMillis / 2;

// Advance timers by half the setup time
act(() => {
jest.advanceTimersByTime(halfTime + 1);
});

await user.click(document.body);

// Advance the remainder of the time to ensure the idle event is not dispatched
act(() => {
jest.advanceTimersByTime(halfTime + 1);
});

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});

it('should not dispatch any event if idle and no interaction happened before timeout', async () => {
renderHook(() => useIdleDetection(args));

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);

act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});

expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);

expect(activeCallback).toHaveBeenCalledTimes(0);

expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);

jest.clearAllMocks();

act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});

expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
});
});
Loading
Loading