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
5 changes: 5 additions & 0 deletions apps/meteor/app/lib/server/functions/getFullUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export async function getFullUserDataByUniqueSearchTerm(

const fields = getFields(canViewAllInfo);

// When PDP type is not local, Rocket.Chat doesn't manage ABAC attributes, so there's no point in fetching them
if (settings.get('ABAC_PDP_Type') !== 'local') {
delete fields.abacAttributes;
}

const options = {
projection: {
...fields,
Expand Down
8 changes: 6 additions & 2 deletions apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ class RoomHistoryManagerClass extends Emitter {

private run(fn: () => void) {
const difference = this.lastRequest ? differenceInMilliseconds(new Date(), this.lastRequest) : Infinity;
if (difference > 500) {
// Original cooldown was 500ms which forced ~330ms wait on the second getMore call when a
// user opens a room. Pagination throughput here is bounded by the loadHistory server
// method itself, so a smaller client-side spacing is enough to avoid hammering.
const minSpacingMs = 100;
if (difference > minSpacingMs) {
return fn();
}
return setTimeout(fn, 500 - difference);
return setTimeout(fn, minSpacingMs - difference);
}

public isLoaded(rid: IRoom['_id']) {
Expand Down
17 changes: 10 additions & 7 deletions apps/meteor/client/sidebar/Item/Condensed.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo, useState } from 'react';
import { memo } from 'react';

import { useDeferredMenuMount } from './useDeferredMenuMount';

type CondensedProps = {
title: ReactNode;
Expand All @@ -18,21 +20,22 @@ type CondensedProps = {
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;

const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => {
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);

const handleFocus = () => setMenuVisibility(true);
const handlePointerEnter = () => setMenuVisibility(true);
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item title={title} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
<SidebarV2Item title={title} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
{avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>}
{icon}
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
{badges}
{actions}
{menu && (
<SidebarV2ItemMenu>
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarV2ItemMenu>
)}
</SidebarV2Item>
Expand Down
16 changes: 9 additions & 7 deletions apps/meteor/client/sidebar/Item/Extended.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
IconButton,
} from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo, useState } from 'react';
import { memo } from 'react';

import { useDeferredMenuMount } from './useDeferredMenuMount';
import { useShortTimeAgo } from '../../hooks/useTimeAgo';

type ExtendedProps = {
Expand Down Expand Up @@ -49,13 +50,10 @@ const Extended = ({
...props
}: ExtendedProps) => {
const formatDate = useShortTimeAgo();
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);

const handleFocus = () => setMenuVisibility(true);
const handlePointerEnter = () => setMenuVisibility(true);
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item title={title} href={href} selected={selected} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
<SidebarV2Item title={title} href={href} selected={selected} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
{avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>}
<SidebarV2ItemCol>
<SidebarV2ItemRow>
Expand All @@ -69,7 +67,11 @@ const Extended = ({
{actions}
{menu && (
<SidebarV2ItemMenu>
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarV2ItemMenu>
)}
</SidebarV2ItemRow>
Expand Down
17 changes: 10 additions & 7 deletions apps/meteor/client/sidebar/Item/Medium.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo, useState } from 'react';
import { memo } from 'react';

import { useDeferredMenuMount } from './useDeferredMenuMount';

type MediumProps = {
title: ReactNode;
Expand All @@ -17,21 +19,22 @@ type MediumProps = {
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;

const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => {
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);

const handleFocus = () => setMenuVisibility(true);
const handlePointerEnter = () => setMenuVisibility(true);
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item title={title} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
<SidebarV2Item title={title} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
<SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>
{icon}
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
{badges}
{actions}
{menu && (
<SidebarV2ItemMenu>
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarV2ItemMenu>
)}
</SidebarV2Item>
Expand Down
59 changes: 59 additions & 0 deletions apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';

type IdleHandle = { type: 'idle' | 'timeout'; id: number };

const schedule = (fn: () => void): IdleHandle => {
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
return { type: 'idle', id: window.requestIdleCallback(fn, { timeout: 200 }) };
}
return { type: 'timeout', id: window.setTimeout(fn, 50) };
};

const cancel = (handle: IdleHandle) => {
if (handle.type === 'idle' && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(handle.id);
return;
}
window.clearTimeout(handle.id);
};

/**
* Defers mounting the sidebar item's RoomMenu until the browser is idle. The menu's hooks
* (useUserSubscription, usePermission, useSetting, useOmnichannelPrioritiesMenu, useUserPresence)
* are not cheap to run synchronously inside the same pointerover/click task as a room navigation,
* so we let the browser finish more urgent work first.
*/
export const useDeferredMenuMount = () => {
const [mounted, setMounted] = useState(typeof window !== 'undefined' && !!window.DISABLE_ANIMATION);
const handleRef = useRef<IdleHandle | undefined>(undefined);

const requestMount = useCallback(() => {
if (mounted || handleRef.current !== undefined) {
return;
}
handleRef.current = schedule(() => {
handleRef.current = undefined;
setMounted(true);
});
}, [mounted]);

const mountNow = useCallback(() => {
if (handleRef.current !== undefined) {
cancel(handleRef.current);
handleRef.current = undefined;
}
setMounted(true);
}, []);

useEffect(
() => () => {
if (handleRef.current !== undefined) {
cancel(handleRef.current);
handleRef.current = undefined;
}
},
[],
);

return { mounted, requestMount, mountNow };
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMen
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
import { memo, useState } from 'react';
import { memo } from 'react';

import { useDeferredMenuMount } from '../../../../sidebar/Item/useDeferredMenuMount';

type SidebarItemProps = {
title: ReactNode;
Expand All @@ -20,13 +22,10 @@ type SidebarItemProps = {
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;

const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...props }: SidebarItemProps) => {
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);

const handleFocus = () => setMenuVisibility(true);
const handlePointerEnter = () => setMenuVisibility(true);
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item {...props} title={title} onFocus={handleFocus} onPointerEnter={handlePointerEnter} aria-selected={props.selected}>
<SidebarV2Item {...props} title={title} onFocus={mountNow} onPointerEnter={requestMount} aria-selected={props.selected}>
<SidebarV2ItemAvatarWrapper>
<RoomAvatar size='x20' room={{ ...room, _id: room.rid || room._id, type: room.t }} />
</SidebarV2ItemAvatarWrapper>
Expand All @@ -36,7 +35,11 @@ const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...prop
{actions}
{menu && (
<SidebarV2ItemMenu>
{menuVisibility ? menu : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
{menuVisibility ? (
menu
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarV2ItemMenu>
)}
</SidebarV2Item>
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ describe('useGetMore', () => {

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();
Expand Down Expand Up @@ -89,6 +92,9 @@ describe('useGetMore', () => {
});
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');
Expand Down
60 changes: 47 additions & 13 deletions apps/meteor/client/views/room/body/hooks/useGetMore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {
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;
Expand Down Expand Up @@ -57,32 +65,58 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {
}
});

const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(() => {
checkPositionAndGetMore();
});
});
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(() => {
checkPositionAndGetMore();
});

const observer = new ResizeObserver(gatedCheck);
observer.observe(element);

const handleScroll = function () {
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();
};

element.addEventListener('scroll', handleScroll, {
passive: true,
});
element.addEventListener('wheel', markInteracted, { passive: true });
element.addEventListener('touchmove', markInteracted, { passive: true });
element.addEventListener('keydown', handleKeydown);
element.addEventListener('scroll', handleScroll, { passive: true });

return () => {
observer.disconnect();
mutationObserver.disconnect();
checkPositionAndGetMore.cancel();
element.removeEventListener('wheel', markInteracted);
element.removeEventListener('touchmove', markInteracted);
element.removeEventListener('keydown', handleKeydown);
element.removeEventListener('scroll', handleScroll);
};
},
Expand Down
Loading
Loading