feat: `RoomSidepanel` (#33225)

Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com>
pull/33179/head
Júlia Jaeger Foresti 1 year ago committed by GitHub
parent 40339749b3
commit 12d6307998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      .changeset/witty-lemons-type.md
  2. 2
      apps/meteor/app/api/server/v1/rooms.ts
  3. 10
      apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx
  4. 4
      apps/meteor/client/hooks/useRoomInfoEndpoint.ts
  5. 14
      apps/meteor/client/hooks/useSidePanelNavigation.ts
  6. 28
      apps/meteor/client/lib/RoomManager.ts
  7. 62
      apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx
  8. 30
      apps/meteor/client/sidebarv2/header/SearchSection.tsx
  9. 80
      apps/meteor/client/views/room/RoomOpener.tsx
  10. 66
      apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx
  11. 19
      apps/meteor/client/views/room/Sidepanel/RoomSidepanelListWrapper.tsx
  12. 20
      apps/meteor/client/views/room/Sidepanel/RoomSidepanelLoading.tsx
  13. 29
      apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx
  14. 1
      apps/meteor/client/views/room/Sidepanel/SidepanelItem/index.ts
  15. 68
      apps/meteor/client/views/room/Sidepanel/hooks/useItemData.tsx
  16. 106
      apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts
  17. 1
      apps/meteor/client/views/room/Sidepanel/index.ts
  18. 68
      apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx
  19. 18
      apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts
  20. 4
      apps/meteor/client/views/room/layout/RoomLayout.tsx
  21. 77
      apps/meteor/client/views/room/providers/RoomProvider.tsx
  22. 1
      apps/meteor/lib/publishFields.ts
  23. 6
      apps/meteor/server/services/team/service.ts
  24. 3
      packages/core-typings/src/IRoom.ts
  25. 4
      packages/i18n/src/locales/en.i18n.json
  26. 2
      packages/rest-typings/src/v1/rooms.ts
  27. 12
      packages/ui-client/src/components/FeaturePreview/FeaturePreview.tsx
  28. 2
      packages/ui-client/src/hooks/useFeaturePreviewList.ts

@ -0,0 +1,10 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/ui-client': minor
'@rocket.chat/meteor': minor
---
Implemented new feature preview for Sidepanel

@ -420,7 +420,7 @@ API.v1.addRoute(
const discussionParent =
room.prid &&
(await Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | 'prid' | 'u'>>(room.prid, {
projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 },
projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1, sidepanel: 1 },
}));
const { team, parentRoom } = await Team.getRoomInfo(room);
const parent = discussionParent || parentRoom;

@ -0,0 +1,10 @@
import { FeaturePreview } from '@rocket.chat/ui-client';
import type { ReactElement } from 'react';
import React from 'react';
import { useSidePanelNavigationScreenSize } from '../hooks/useSidePanelNavigation';
export const FeaturePreviewSidePanelNavigation = ({ children }: { children: ReactElement[] }) => {
const disabled = !useSidePanelNavigationScreenSize();
return <FeaturePreview feature='sidepanelNavigation' disabled={disabled} children={children} />;
};

@ -1,6 +1,6 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { minutesToMilliseconds } from 'date-fns';
@ -8,6 +8,7 @@ import type { Meteor } from 'meteor/meteor';
export const useRoomInfoEndpoint = (rid: IRoom['_id']): UseQueryResult<OperationResult<'GET', '/v1/rooms.info'>> => {
const getRoomInfo = useEndpoint('GET', '/v1/rooms.info');
const uid = useUserId();
return useQuery(['/v1/rooms.info', rid], () => getRoomInfo({ roomId: rid }), {
cacheTime: minutesToMilliseconds(15),
staleTime: minutesToMilliseconds(5),
@ -17,5 +18,6 @@ export const useRoomInfoEndpoint = (rid: IRoom['_id']): UseQueryResult<Operation
}
return true;
},
enabled: !!uid,
});
};

@ -0,0 +1,14 @@
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import { useFeaturePreview } from '@rocket.chat/ui-client';
export const useSidePanelNavigation = () => {
const isSidepanelFeatureEnabled = useFeaturePreview('sidepanelNavigation');
// ["xs", "sm", "md", "lg", "xl", xxl"]
return useSidePanelNavigationScreenSize() && isSidepanelFeatureEnabled;
};
export const useSidePanelNavigationScreenSize = () => {
const breakpoints = useBreakpoints();
// ["xs", "sm", "md", "lg", "xl", xxl"]
return breakpoints.includes('lg');
};

@ -55,6 +55,8 @@ export const RoomManager = new (class RoomManager extends Emitter<{
private rooms: Map<IRoom['_id'], RoomStore> = new Map();
private parentRid?: IRoom['_id'] | undefined;
constructor() {
super();
debugRoomManager &&
@ -78,6 +80,13 @@ export const RoomManager = new (class RoomManager extends Emitter<{
}
get opened(): IRoom['_id'] | undefined {
return this.parentRid ?? this.rid;
}
get openedSecondLevel(): IRoom['_id'] | undefined {
if (!this.parentRid) {
return undefined;
}
return this.rid;
}
@ -106,20 +115,28 @@ export const RoomManager = new (class RoomManager extends Emitter<{
this.emit('changed', this.rid);
}
open(rid: IRoom['_id']): void {
private _open(rid: IRoom['_id'], parent?: IRoom['_id']): void {
if (rid === this.rid) {
return;
}
this.back(rid);
if (!this.rooms.has(rid)) {
this.rooms.set(rid, new RoomStore(rid));
}
this.rid = rid;
this.parentRid = parent;
this.emit('opened', this.rid);
this.emit('changed', this.rid);
}
open(rid: IRoom['_id']): void {
this._open(rid);
}
openSecondLevel(parentId: IRoom['_id'], rid: IRoom['_id']): void {
this._open(rid, parentId);
}
getStore(rid: IRoom['_id']): RoomStore | undefined {
return this.rooms.get(rid);
}
@ -130,4 +147,11 @@ const subscribeOpenedRoom = [
(): IRoom['_id'] | undefined => RoomManager.opened,
] as const;
const subscribeOpenedSecondLevelRoom = [
(callback: () => void): (() => void) => RoomManager.on('changed', callback),
(): IRoom['_id'] | undefined => RoomManager.openedSecondLevel,
] as const;
export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom);
export const useSecondLevelOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedSecondLevelRoom);

@ -1,3 +1,4 @@
import type { SidepanelItem } from '@rocket.chat/core-typings';
import {
Box,
Button,
@ -16,6 +17,7 @@ import {
AccordionItem,
} from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
import {
useEndpoint,
usePermission,
@ -40,6 +42,8 @@ type CreateTeamModalInputs = {
encrypted: boolean;
broadcast: boolean;
members?: string[];
showDiscussions?: boolean;
showChannels?: boolean;
};
type CreateTeamModalProps = { onClose: () => void };
@ -50,6 +54,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms');
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const dispatchToastMessage = useToastMessageDispatch();
const canCreateTeam = usePermission('create-team');
const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']);
@ -94,6 +99,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false,
broadcast: false,
members: [],
showChannels: true,
showDiscussions: true,
},
});
@ -123,7 +130,10 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
topic,
broadcast,
encrypted,
showChannels,
showDiscussions,
}: CreateTeamModalInputs): Promise<void> => {
const sidepanelItem = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [SidepanelItem, SidepanelItem?];
const params = {
name,
members,
@ -136,6 +146,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
encrypted,
},
},
...((showChannels || showDiscussions) && { sidepanel: { items: sidepanelItem } }),
};
try {
@ -157,6 +168,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
const encryptedId = useUniqueId();
const broadcastId = useUniqueId();
const addMembersId = useUniqueId();
const showChannelsId = useUniqueId();
const showDiscussionsId = useUniqueId();
return (
<Modal
@ -236,6 +249,55 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
</FieldGroup>
<Accordion>
<AccordionItem title={t('Advanced_settings')}>
<FeaturePreview feature='sidepanelNavigation'>
<FeaturePreviewOff>{null}</FeaturePreviewOff>
<FeaturePreviewOn>
<FieldGroup>
<Box is='h5' fontScale='h5' color='titles-labels'>
{t('Navigation')}
</Box>
<Field>
<FieldRow>
<FieldLabel htmlFor={showChannelsId}>{t('Channels')}</FieldLabel>
<Controller
control={control}
name='showChannels'
render={({ field: { onChange, value, ref } }): ReactElement => (
<ToggleSwitch
aria-describedby={`${showChannelsId}-hint`}
id={showChannelsId}
onChange={onChange}
checked={value}
ref={ref}
/>
)}
/>
</FieldRow>
<FieldDescription id={`${showChannelsId}-hint`}>{t('Show_channels_description')}</FieldDescription>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={showDiscussionsId}>{t('Discussions')}</FieldLabel>
<Controller
control={control}
name='showDiscussions'
render={({ field: { onChange, value, ref } }): ReactElement => (
<ToggleSwitch
aria-describedby={`${showDiscussionsId}-hint`}
id={showDiscussionsId}
onChange={onChange}
checked={value}
ref={ref}
/>
)}
/>
</FieldRow>
<FieldDescription id={`${showDiscussionsId}-hint`}>{t('Show_discussions_description')}</FieldDescription>
</Field>
</FieldGroup>
</FeaturePreviewOn>
</FeaturePreview>
<FieldGroup>
<Box is='h5' fontScale='h5' color='titles-labels'>
{t('Security_and_permissions')}

@ -22,6 +22,32 @@ const wrapperStyle = css`
background-color: ${Palette.surface['surface-sidebar']};
`;
const mobileCheck = function () {
let check = false;
(function (a: string) {
if (
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
a,
) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
a.substr(0, 4),
)
)
check = true;
})(navigator.userAgent || navigator.vendor || window.opera || '');
return check;
};
const shortcut = ((): string => {
if (navigator.userAgentData?.mobile || mobileCheck()) {
return '';
}
if (window.navigator.platform.toLowerCase().includes('mac')) {
return '(\u2318+K)';
}
return '(Ctrl+K)';
})();
const SearchSection = () => {
const t = useTranslation();
const user = useUser();
@ -68,11 +94,13 @@ const SearchSection = () => {
};
}, [handleEscSearch, setFocus]);
const placeholder = [t('Search'), shortcut].filter(Boolean).join(' ');
return (
<Box className={['rcx-sidebar', isDirty && wrapperStyle]} ref={wrapperRef} role='search'>
<SidebarV2Section>
<TextInput
placeholder={t('Search')}
placeholder={placeholder}
{...rest}
ref={mergedRefs}
role='searchbox'

@ -1,15 +1,18 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
import type { ReactElement } from 'react';
import React, { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { FeaturePreviewSidePanelNavigation } from '../../components/FeaturePreviewSidePanelNavigation';
import { Header } from '../../components/Header';
import { getErrorMessage } from '../../lib/errorHandling';
import { NotAuthorizedError } from '../../lib/errors/NotAuthorizedError';
import { OldUrlRoomError } from '../../lib/errors/OldUrlRoomError';
import { RoomNotFoundError } from '../../lib/errors/RoomNotFoundError';
import RoomSkeleton from './RoomSkeleton';
import RoomSidepanel from './Sidepanel/RoomSidepanel';
import { useOpenRoom } from './hooks/useOpenRoom';
const RoomProvider = lazy(() => import('./providers/RoomProvider'));
@ -23,46 +26,59 @@ type RoomOpenerProps = {
reference: string;
};
const isDirectOrOmnichannelRoom = (type: RoomType) => type === 'd' || type === 'l';
const RoomOpener = ({ type, reference }: RoomOpenerProps): ReactElement => {
const { data, error, isSuccess, isError, isLoading } = useOpenRoom({ type, reference });
const { t } = useTranslation();
return (
<Suspense fallback={<RoomSkeleton />}>
{isLoading && <RoomSkeleton />}
{isSuccess && (
<RoomProvider rid={data.rid}>
<Room />
</RoomProvider>
<Box display='flex' w='full' h='full'>
{!isDirectOrOmnichannelRoom(type) && (
<FeaturePreviewSidePanelNavigation>
<FeaturePreviewOff>{null}</FeaturePreviewOff>
<FeaturePreviewOn>
<RoomSidepanel />
</FeaturePreviewOn>
</FeaturePreviewSidePanelNavigation>
)}
{isError &&
(() => {
if (error instanceof OldUrlRoomError) {
return <RoomSkeleton />;
}
if (error instanceof RoomNotFoundError) {
return <RoomNotFound />;
}
<Suspense fallback={<RoomSkeleton />}>
{isLoading && <RoomSkeleton />}
{isSuccess && (
<RoomProvider rid={data.rid}>
<Room />
</RoomProvider>
)}
{isError &&
(() => {
if (error instanceof OldUrlRoomError) {
return <RoomSkeleton />;
}
if (error instanceof RoomNotFoundError) {
return <RoomNotFound />;
}
if (error instanceof NotAuthorizedError) {
return <NotAuthorizedPage />;
}
if (error instanceof NotAuthorizedError) {
return <NotAuthorizedPage />;
}
return (
<RoomLayout
header={<Header />}
body={
<States>
<StatesIcon name='circle-exclamation' variation='danger' />
<StatesTitle>{t('core.Error')}</StatesTitle>
<StatesSubtitle>{getErrorMessage(error)}</StatesSubtitle>
</States>
}
/>
);
})()}
</Suspense>
return (
<RoomLayout
header={<Header />}
body={
<States>
<StatesIcon name='circle-exclamation' variation='danger' />
<StatesTitle>{t('core.Error')}</StatesTitle>
<StatesSubtitle>{getErrorMessage(error)}</StatesSubtitle>
</States>
}
/>
);
})()}
</Suspense>
</Box>
);
};

@ -0,0 +1,66 @@
/* eslint-disable react/no-multi-comp */
import { Box, Sidepanel, SidepanelListItem } from '@rocket.chat/fuselage';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { VirtuosoScrollbars } from '../../../components/CustomScrollbars';
import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint';
import { useOpenedRoom, useSecondLevelOpenedRoom } from '../../../lib/RoomManager';
import RoomSidepanelListWrapper from './RoomSidepanelListWrapper';
import RoomSidepanelLoading from './RoomSidepanelLoading';
import RoomSidepanelItem from './SidepanelItem';
import { useTeamsListChildrenUpdate } from './hooks/useTeamslistChildren';
const RoomSidepanel = () => {
const parentRid = useOpenedRoom();
const secondLevelOpenedRoom = useSecondLevelOpenedRoom() ?? parentRid;
if (!parentRid || !secondLevelOpenedRoom) {
return null;
}
return <RoomSidepanelWithData parentRid={parentRid} openedRoom={secondLevelOpenedRoom} />;
};
const RoomSidepanelWithData = ({ parentRid, openedRoom }: { parentRid: string; openedRoom: string }) => {
const sidebarViewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode');
const roomInfo = useRoomInfoEndpoint(parentRid);
const sidepanelItems = roomInfo.data?.room?.sidepanel?.items || roomInfo.data?.parent?.sidepanel?.items;
const result = useTeamsListChildrenUpdate(
parentRid,
!roomInfo.data ? null : roomInfo.data.room?.teamId,
// eslint-disable-next-line no-nested-ternary
!sidepanelItems ? null : sidepanelItems?.length === 1 ? sidepanelItems[0] : undefined,
);
if (roomInfo.isSuccess && !roomInfo.data.room?.sidepanel && !roomInfo.data.parent?.sidepanel) {
return null;
}
if (roomInfo.isLoading || (roomInfo.isSuccess && result.isLoading)) {
return <RoomSidepanelLoading />;
}
if (!result.isSuccess || !roomInfo.isSuccess) {
return null;
}
return (
<Sidepanel>
<Box pb={8} h='full'>
<Virtuoso
totalCount={result.data.data.length}
data={result.data.data}
components={{ Item: SidepanelListItem, List: RoomSidepanelListWrapper, Scroller: VirtuosoScrollbars }}
itemContent={(_, data) => (
<RoomSidepanelItem openedRoom={openedRoom} room={data} parentRid={parentRid} viewMode={sidebarViewMode} />
)}
/>
</Box>
</Sidepanel>
);
};
export default memo(RoomSidepanel);

@ -0,0 +1,19 @@
import { SidepanelList } from '@rocket.chat/fuselage';
import { useMergedRefs } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ForwardedRef, HTMLAttributes } from 'react';
import React, { forwardRef } from 'react';
import { useSidebarListNavigation } from '../../../sidebar/RoomList/useSidebarListNavigation';
type RoomListWrapperProps = HTMLAttributes<HTMLDivElement>;
const RoomSidepanelListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef<HTMLDivElement>) {
const t = useTranslation();
const { sidebarListRef } = useSidebarListNavigation();
const mergedRefs = useMergedRefs(ref, sidebarListRef);
return <SidepanelList aria-label={t('Channels')} ref={mergedRefs} {...props} />;
});
export default RoomSidepanelListWrapper;

@ -0,0 +1,20 @@
import { SidebarV2Item as SidebarItem, Sidepanel, SidepanelList, Skeleton } from '@rocket.chat/fuselage';
import React from 'react';
const RoomSidepanelLoading = () => (
<Sidepanel>
<SidepanelList>
<SidebarItem>
<Skeleton w='full' />
</SidebarItem>
<SidebarItem>
<Skeleton w='full' />
</SidebarItem>
<SidebarItem>
<Skeleton w='full' />
</SidebarItem>
</SidepanelList>
</Sidepanel>
);
export default RoomSidepanelLoading;

@ -0,0 +1,29 @@
import type { IRoom, ISubscription, Serialized } from '@rocket.chat/core-typings';
import { useUserSubscription } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';
import { goToRoomById } from '../../../../lib/utils/goToRoomById';
import { useTemplateByViewMode } from '../../../../sidebarv2/hooks/useTemplateByViewMode';
import { useItemData } from '../hooks/useItemData';
export type RoomSidepanelItemProps = {
openedRoom?: string;
room: Serialized<IRoom>;
parentRid: string;
viewMode?: 'extended' | 'medium' | 'condensed';
};
const RoomSidepanelItem = ({ room, openedRoom, viewMode }: RoomSidepanelItemProps) => {
const SidepanelItem = useTemplateByViewMode();
const subscription = useUserSubscription(room._id);
const itemData = useItemData({ ...room, ...subscription } as ISubscription & IRoom, { viewMode, openedRoom }); // as any because of divergent and overlaping timestamp types in subs and room (type Date vs type string)
if (!subscription) {
return <SidepanelItem onClick={goToRoomById} is='a' {...itemData} />;
}
return <SidepanelItem onClick={goToRoomById} {...itemData} />;
};
export default memo(RoomSidepanelItem);

@ -0,0 +1 @@
export { default } from './RoomSidepanelItem';

@ -0,0 +1,68 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { SidebarV2ItemBadge as SidebarItemBadge, SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
import { RoomIcon } from '../../../../components/RoomIcon';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { getBadgeTitle, getMessage } from '../../../../sidebarv2/RoomList/SidebarItemTemplateWithData';
import { useAvatarTemplate } from '../../../../sidebarv2/hooks/useAvatarTemplate';
export const useItemData = (
room: ISubscription & IRoom,
{ openedRoom, viewMode }: { openedRoom: string | undefined; viewMode?: 'extended' | 'medium' | 'condensed' },
) => {
const t = useTranslation();
const AvatarTemplate = useAvatarTemplate();
const highlighted = Boolean(!room.hideUnreadStatus && (room.alert || room.unread));
const icon = useMemo(
() => <SidebarItemIcon highlighted={highlighted} icon={<RoomIcon room={room} placement='sidebar' size='x20' />} />,
[highlighted, room],
);
const time = 'lastMessage' in room ? room.lastMessage?.ts : undefined;
const message = viewMode === 'extended' && getMessage(room, room.lastMessage, t);
const threadUnread = Number(room.tunread?.length) > 0;
const isUnread = room.unread > 0 || threadUnread;
const showBadge =
!room.hideUnreadStatus || (!room.hideMentionStatus && (Boolean(room.userMentions) || Number(room.tunreadUser?.length) > 0));
const badgeTitle = getBadgeTitle(room.userMentions, Number(room.tunread?.length), room.groupMentions, room.unread, t);
const variant =
((room.userMentions || room.tunreadUser?.length) && 'danger') ||
(threadUnread && 'primary') ||
(room.groupMentions && 'warning') ||
'secondary';
const badges = useMemo(
() => (
<>
{showBadge && isUnread && (
<SidebarItemBadge variant={variant} title={badgeTitle}>
{room.unread + (room.tunread?.length || 0)}
</SidebarItemBadge>
)}
</>
),
[badgeTitle, isUnread, room.tunread?.length, room.unread, showBadge, variant],
);
const itemData = useMemo(
() => ({
unread: highlighted,
selected: room.rid === openedRoom,
t,
href: roomCoordinator.getRouteLink(room.t, room) || '',
title: roomCoordinator.getRoomName(room.t, room) || '',
icon,
time,
badges,
avatar: AvatarTemplate && <AvatarTemplate {...room} />,
subtitle: message,
}),
[AvatarTemplate, badges, highlighted, icon, message, openedRoom, room, t, time],
);
return itemData;
};

@ -0,0 +1,106 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { ChatRoom } from '../../../../../app/models/client';
const sortRoomByLastMessage = (a: IRoom, b: IRoom) => {
if (!a.lm) {
return 1;
}
if (!b.lm) {
return -1;
}
return new Date(b.lm).toUTCString().localeCompare(new Date(a.lm).toUTCString());
};
export const useTeamsListChildrenUpdate = (
parentRid: string,
teamId?: string | null,
sidepanelItems?: 'channels' | 'discussions' | null,
) => {
const queryClient = useQueryClient();
const query = useMemo(() => {
const query: Parameters<typeof ChatRoom.find>[0] = {
$or: [
{
_id: parentRid,
},
{
prid: parentRid,
},
],
};
if (teamId && query.$or) {
query.$or.push({
teamId,
});
}
return query;
}, [parentRid, teamId]);
const teamList = useEndpoint('GET', '/v1/teams.listChildren');
const listRoomsAndDiscussions = useEndpoint('GET', '/v1/teams.listChildren');
const result = useQuery({
queryKey: ['sidepanel', 'list', parentRid, sidepanelItems],
queryFn: () =>
listRoomsAndDiscussions({
roomId: parentRid,
sort: JSON.stringify({ lm: -1 }),
type: sidepanelItems || undefined,
}),
enabled: sidepanelItems !== null && teamId !== null,
refetchInterval: 5 * 60 * 1000,
keepPreviousData: true,
});
const { mutate: update } = useMutation({
mutationFn: async (params?: { action: 'add' | 'remove' | 'update'; data: IRoom }) => {
queryClient.setQueryData(['sidepanel', 'list', parentRid, sidepanelItems], (data: Awaited<ReturnType<typeof teamList>> | void) => {
if (!data) {
return;
}
if (params?.action === 'add') {
data.data = [JSON.parse(JSON.stringify(params.data)), ...data.data].sort(sortRoomByLastMessage);
}
if (params?.action === 'remove') {
data.data = data.data.filter((item) => item._id !== params.data?._id);
}
if (params?.action === 'update') {
data.data = data.data
.map((item) => (item._id === params.data?._id ? JSON.parse(JSON.stringify(params.data)) : item))
.sort(sortRoomByLastMessage);
}
return { ...data };
});
},
});
useEffect(() => {
const liveQueryHandle = ChatRoom.find(query).observe({
added: (item) => {
queueMicrotask(() => update({ action: 'add', data: item }));
},
changed: (item) => {
queueMicrotask(() => update({ action: 'update', data: item }));
},
removed: (item) => {
queueMicrotask(() => update({ action: 'remove', data: item }));
},
});
return () => {
liveQueryHandle.stop();
};
}, [update, query]);
return result;
};

@ -0,0 +1 @@
export { default } from './RoomSidepanel';

@ -1,5 +1,5 @@
/* eslint-disable complexity */
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import type { IRoomWithRetentionPolicy, SidepanelItem } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import {
@ -21,10 +21,13 @@ import {
Box,
TextAreaInput,
AccordionItem,
Divider,
} from '@rocket.chat/fuselage';
import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useTranslation, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { ChangeEvent } from 'react';
import React, { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
@ -72,11 +75,12 @@ const getRetentionSetting = (roomType: IRoomWithRetentionPolicy['t']): string =>
};
const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => {
const query = useQueryClient();
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const isFederated = useMemo(() => isRoomFederated(room), [room]);
// eslint-disable-next-line no-nested-ternary
const roomType = 'prid' in room ? 'discussion' : room.teamId ? 'team' : 'channel';
const roomType = 'prid' in room ? 'discussion' : room.teamMain ? 'team' : 'channel';
const retentionPolicy = useRetentionPolicy(room);
const retentionMaxAgeDefault = msToTimeUnit(TIMEUNIT.days, Number(useSetting<number>(getRetentionSetting(room.t)))) ?? 30;
@ -118,6 +122,8 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
retentionOverrideGlobal,
roomType: roomTypeP,
reactWhenReadOnly,
showChannels,
showDiscussions,
} = watch();
const {
@ -158,13 +164,23 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
retentionIgnoreThreads,
...formData
}) => {
const data = getDirtyFields(formData, dirtyFields);
const data = getDirtyFields<Partial<typeof defaultValues>>(formData, dirtyFields);
delete data.archived;
delete data.showChannels;
delete data.showDiscussions;
const sidepanelItems = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [
SidepanelItem,
SidepanelItem?,
];
const sidepanel = sidepanelItems.length > 0 ? { items: sidepanelItems } : null;
try {
await saveAction({
rid: room._id,
...data,
...(roomType === 'team' ? { sidepanel } : null),
...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }),
...((data.systemMessages || !hideSysMes) && {
systemMessages: hideSysMes && data.systemMessages,
@ -180,6 +196,7 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
}),
});
await query.invalidateQueries(['/v1/rooms.info', room._id]);
dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') });
onClickClose();
} catch (error) {
@ -224,6 +241,8 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
const retentionExcludePinnedField = useUniqueId();
const retentionFilesOnlyField = useUniqueId();
const retentionIgnoreThreads = useUniqueId();
const showDiscussionsField = useUniqueId();
const showChannelsField = useUniqueId();
const showAdvancedSettings = canViewEncrypted || canViewReadOnly || readOnly || canViewArchived || canViewJoinCode || canViewHideSysMes;
const showRetentionPolicy = canEditRoomRetentionPolicy && retentionPolicy?.enabled;
@ -355,6 +374,49 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
<Accordion>
{showAdvancedSettings && (
<AccordionItem title={t('Advanced_settings')}>
{roomType === 'team' && (
<FeaturePreview feature='sidepanelNavigation'>
<FeaturePreviewOff>{null}</FeaturePreviewOff>
<FeaturePreviewOn>
<FieldGroup>
<Box is='h5' fontScale='h5' color='titles-labels'>
{t('Navigation')}
</Box>
<Field>
<FieldRow>
<FieldLabel htmlFor={showChannelsField}>{t('Channels')}</FieldLabel>
<Controller
control={control}
name='showChannels'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={showChannelsField} checked={value} {...field} />
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${showChannelsField}-hint`}>{t('Show_channels_description')}</FieldHint>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={showDiscussionsField}>{t('Discussions')}</FieldLabel>
<Controller
control={control}
name='showDiscussions'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={showDiscussionsField} checked={value} {...field} />
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${showDiscussionsField}-hint`}>{t('Show_discussions_description')}</FieldHint>
</FieldRow>
</Field>
</FieldGroup>
<Divider mb={24} />
</FeaturePreviewOn>
</FeaturePreview>
)}
<FieldGroup>
<Box is='h5' fontScale='h5' color='titles-labels'>
{t('Security_and_permissions')}

@ -10,7 +10,20 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => {
const retentionPolicy = useRetentionPolicy(room);
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
const { t, ro, archived, topic, description, announcement, joinCodeRequired, sysMes, encrypted, retention, reactWhenReadOnly } = room;
const {
t,
ro,
archived,
topic,
description,
announcement,
joinCodeRequired,
sysMes,
encrypted,
retention,
reactWhenReadOnly,
sidepanel,
} = room;
return useMemo(
() => ({
@ -37,6 +50,8 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => {
retentionFilesOnly: retention?.filesOnly ?? retentionPolicy.filesOnly,
retentionIgnoreThreads: retention?.ignoreThreads ?? retentionPolicy.ignoreThreads,
}),
showDiscussions: sidepanel?.items.includes('discussions'),
showChannels: sidepanel?.items.includes('channels'),
}),
[
announcement,
@ -53,6 +68,7 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => {
encrypted,
reactWhenReadOnly,
canEditRoomRetentionPolicy,
sidepanel,
],
);
};

@ -59,7 +59,7 @@ const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps):
[layout, contextualbarPosition, contextualbarSize],
)}
>
<Box h='full' display='flex' flexDirection='column' bg='room' {...props} ref={ref}>
<Box h='full' w='full' display='flex' flexDirection='column' bg='room' {...props} ref={ref}>
<Suspense
fallback={
<FeaturePreview feature='newNavigation'>
@ -82,7 +82,7 @@ const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps):
{footer && <Suspense fallback={null}>{footer}</Suspense>}
</Box>
{aside && (
<ContextualbarDialog>
<ContextualbarDialog position={contextualbarPosition}>
<Suspense fallback={null}>{aside}</Suspense>
</ContextualbarDialog>
)}

@ -8,12 +8,15 @@ import { RoomHistoryManager } from '../../../../app/ui-utils/client';
import { UserAction } from '../../../../app/ui/client/lib/UserAction';
import { useReactiveQuery } from '../../../hooks/useReactiveQuery';
import { useReactiveValue } from '../../../hooks/useReactiveValue';
import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint';
import { useSidePanelNavigation } from '../../../hooks/useSidePanelNavigation';
import { RoomManager } from '../../../lib/RoomManager';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import ImageGalleryProvider from '../../../providers/ImageGalleryProvider';
import RoomNotFound from '../RoomNotFound';
import RoomSkeleton from '../RoomSkeleton';
import { useRoomRolesManagement } from '../body/hooks/useRoomRolesManagement';
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
import { RoomContext } from '../contexts/RoomContext';
import ComposerPopupProvider from './ComposerPopupProvider';
import RoomToolboxProvider from './RoomToolboxProvider';
@ -30,15 +33,17 @@ type RoomProviderProps = {
const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
useRoomRolesManagement(rid);
const { data: room, isSuccess } = useRoomQuery(rid);
const resultFromServer = useRoomInfoEndpoint(rid);
const resultFromLocal = useRoomQuery(rid);
// TODO: the following effect is a workaround while we don't have a general and definitive solution for it
const router = useRouter();
useEffect(() => {
if (isSuccess && !room) {
if (resultFromLocal.isSuccess && !resultFromLocal.data) {
router.navigate('/home');
}
}, [isSuccess, room, router]);
}, [resultFromLocal.data, resultFromLocal.isSuccess, resultFromServer, router]);
const subscriptionQuery = useReactiveQuery(['subscriptions', { rid }], () => ChatSubscription.findOne({ rid }) ?? null);
@ -46,7 +51,8 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
useUsersNameChanged();
const pseudoRoom = useMemo(() => {
const pseudoRoom: IRoomWithFederationOriginalName | null = useMemo(() => {
const room = resultFromLocal.data;
if (!room) {
return null;
}
@ -57,7 +63,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
name: roomCoordinator.getRoomName(room.t, room),
federationOriginalName: room.name,
};
}, [room, subscriptionQuery.data]);
}, [resultFromLocal.data, subscriptionQuery.data]);
const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useReactiveValue(
useCallback(() => {
@ -86,12 +92,69 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
};
}, [hasMoreNextMessages, hasMorePreviousMessages, isLoadingMoreMessages, pseudoRoom, rid, subscriptionQuery.data]);
const isSidepanelFeatureEnabled = useSidePanelNavigation();
useEffect(() => {
if (isSidepanelFeatureEnabled) {
if (resultFromServer.isSuccess) {
if (resultFromServer.data.room?.teamMain) {
if (
resultFromServer.data.room.sidepanel?.items.includes('channels') ||
resultFromServer.data.room?.sidepanel?.items.includes('discussions')
) {
RoomManager.openSecondLevel(rid, rid);
} else {
RoomManager.open(rid);
}
return (): void => {
RoomManager.back(rid);
};
}
switch (true) {
case resultFromServer.data.room?.prid &&
resultFromServer.data.parent &&
resultFromServer.data.parent.sidepanel?.items.includes('discussions'):
RoomManager.openSecondLevel(resultFromServer.data.parent._id, rid);
break;
case resultFromServer.data.team?.roomId &&
!resultFromServer.data.room?.teamMain &&
resultFromServer.data.parent?.sidepanel?.items.includes('channels'):
RoomManager.openSecondLevel(resultFromServer.data.team.roomId, rid);
break;
default:
if (
resultFromServer.data.parent?.sidepanel?.items.includes('channels') ||
resultFromServer.data.parent?.sidepanel?.items.includes('discussions')
) {
RoomManager.openSecondLevel(rid, rid);
} else {
RoomManager.open(rid);
}
break;
}
}
return (): void => {
RoomManager.back(rid);
};
}
RoomManager.open(rid);
return (): void => {
RoomManager.back(rid);
};
}, [rid]);
}, [
isSidepanelFeatureEnabled,
rid,
resultFromServer.data?.room?.prid,
resultFromServer.data?.room?.teamId,
resultFromServer.data?.room?.teamMain,
resultFromServer.isSuccess,
resultFromServer.data?.parent,
resultFromServer.data?.team?.roomId,
resultFromServer.data,
]);
const subscribed = !!subscriptionQuery.data;
@ -104,7 +167,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
}, [rid, subscribed]);
if (!pseudoRoom) {
return isSuccess && !room ? <RoomNotFound /> : <RoomSkeleton />;
return resultFromLocal.isSuccess && !resultFromLocal.data ? <RoomNotFound /> : <RoomSkeleton />;
}
return (

@ -74,6 +74,7 @@ export const roomFields = {
avatarETag: 1,
usersCount: 1,
msgs: 1,
sidepanel: 1,
// @TODO create an API to register this fields based on room type
tags: 1,

@ -1055,8 +1055,10 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
return rooms;
}
private getParentRoom(team: AtLeast<ITeam, 'roomId'>): Promise<Pick<IRoom, 'name' | 'fname' | 't' | '_id'> | null> {
return Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | '_id'>>(team.roomId, { projection: { name: 1, fname: 1, t: 1 } });
private getParentRoom(team: AtLeast<ITeam, 'roomId'>): Promise<Pick<IRoom, 'name' | 'fname' | 't' | '_id' | 'sidepanel'> | null> {
return Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | '_id' | 'sidepanel'>>(team.roomId, {
projection: { name: 1, fname: 1, t: 1, sidepanel: 1 },
});
}
async getRoomInfo(

@ -99,6 +99,9 @@ export const isSidepanelItem = (item: any): item is SidepanelItem => {
};
export const isValidSidepanel = (sidepanel: IRoom['sidepanel']) => {
if (sidepanel === null) {
return true;
}
if (!sidepanel?.items) {
return false;
}

@ -6541,8 +6541,8 @@
"Incoming_Calls": "Incoming calls",
"Advanced_settings": "Advanced settings",
"Security_and_permissions": "Security and permissions",
"Sidepanel_navigation": "Sidepanel navigation for teams",
"Sidepanel_navigation_description": "Option to open a sidepanel with channels and/or discussions associated with the team. This allows team owners to customize communication methods to best meet their team’s needs. This feature is only available when Enhanced navigation experience is enabled.",
"Sidepanel_navigation": "Secondary navigation for teams",
"Sidepanel_navigation_description": "Display channels and/or discussions associated with teams by default. This allows team owners to customize communication methods to best meet their team’s needs. This is currently in feature preview and will be a premium capability once fully released.",
"Show_channels_description": "Show team channels in second sidebar",
"Show_discussions_description": "Show team discussions in second sidebar"
}

@ -626,7 +626,7 @@ export type RoomsEndpoints = {
'/v1/rooms.info': {
GET: (params: RoomsInfoProps) => {
room: IRoom | undefined;
parent?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'prid' | 'u'>;
parent?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'prid' | 'u' | 'sidepanel'>;
team?: Pick<ITeam, 'name' | 'roomId' | 'type' | '_id'>;
};
};

@ -5,8 +5,16 @@ import { Children, Suspense, cloneElement } from 'react';
import { useFeaturePreview } from '../../hooks/useFeaturePreview';
import { FeaturesAvailable } from '../../hooks/useFeaturePreviewList';
export const FeaturePreview = ({ feature, children }: { feature: FeaturesAvailable; children: ReactElement[] }) => {
const featureToggleEnabled = useFeaturePreview(feature);
export const FeaturePreview = ({
feature,
disabled = false,
children,
}: {
disabled?: boolean;
feature: FeaturesAvailable;
children: ReactElement[];
}) => {
const featureToggleEnabled = useFeaturePreview(feature) && !disabled;
const toggledChildren = Children.map(children, (child) =>
cloneElement(child, {

@ -72,7 +72,7 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [
description: 'Sidepanel_navigation_description',
group: 'Navigation',
value: false,
enabled: false,
enabled: true,
enableQuery: {
name: 'newNavigation',
value: true,

Loading…
Cancel
Save