diff --git a/apps/meteor/client/hooks/menuActions/useLeaveRoom.tsx b/apps/meteor/client/hooks/menuActions/useLeaveRoom.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b5c90c8f42daca2c2035e88c2a5bd9697a56ab0e --- /dev/null +++ b/apps/meteor/client/hooks/menuActions/useLeaveRoom.tsx @@ -0,0 +1,65 @@ +import type { RoomType } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import { LegacyRoomManager } from '../../../app/ui-utils/client'; +import { UiTextContext } from '../../../definition/IRoomTypeConfig'; +import WarningModal from '../../components/WarningModal'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +const leaveEndpoints = { + p: '/v1/groups.leave', + c: '/v1/channels.leave', + d: '/v1/im.leave', + v: '/v1/channels.leave', + l: '/v1/groups.leave', +} as const; + +type LeaveRoomProps = { + rid: string; + type: RoomType; + name: string; + roomOpen?: boolean; +}; + +// TODO: this menu action should consider team leaving +export const useLeaveRoomAction = ({ rid, type, name, roomOpen }: LeaveRoomProps) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const router = useRouter(); + + const leaveRoom = useEndpoint('POST', leaveEndpoints[type]); + + const handleLeave = useEffectEvent(() => { + const leave = async (): Promise<void> => { + try { + await leaveRoom({ roomId: rid }); + if (roomOpen) { + router.navigate('/home'); + } + LegacyRoomManager.close(rid); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setModal(null); + } + }; + + const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING); + + setModal( + <WarningModal + text={t(warnText as TranslationKey, name)} + confirmText={t('Leave_room')} + close={() => setModal(null)} + cancelText={t('Cancel')} + confirm={leave} + />, + ); + }); + + return handleLeave; +}; diff --git a/apps/meteor/client/hooks/menuActions/useToggleFavoriteAction.ts b/apps/meteor/client/hooks/menuActions/useToggleFavoriteAction.ts new file mode 100644 index 0000000000000000000000000000000000000000..70284057aee78b6231fe651933d992ea31bf73db --- /dev/null +++ b/apps/meteor/client/hooks/menuActions/useToggleFavoriteAction.ts @@ -0,0 +1,18 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; + +export const useToggleFavoriteAction = ({ rid, isFavorite }: { rid: IRoom['_id']; isFavorite: boolean }) => { + const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleToggleFavorite = useEffectEvent(async () => { + try { + await toggleFavorite({ roomId: rid, favorite: !isFavorite }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return handleToggleFavorite; +}; diff --git a/apps/meteor/client/hooks/menuActions/useToggleReadAction.ts b/apps/meteor/client/hooks/menuActions/useToggleReadAction.ts new file mode 100644 index 0000000000000000000000000000000000000000..133acd9b0f78991eda14f10e40d6257e9dd4e1bc --- /dev/null +++ b/apps/meteor/client/hooks/menuActions/useToggleReadAction.ts @@ -0,0 +1,48 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useMethod, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; + +import { LegacyRoomManager } from '../../../app/ui-utils/client'; + +type ToggleReadActionProps = { + rid: string; + isUnread?: boolean; + subscription?: ISubscription; +}; + +export const useToggleReadAction = ({ rid, isUnread, subscription }: ToggleReadActionProps) => { + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const router = useRouter(); + + const readMessages = useEndpoint('POST', '/v1/subscriptions.read'); + const unreadMessages = useMethod('unreadMessages'); + + const handleToggleRead = useEffectEvent(async () => { + try { + queryClient.invalidateQueries({ + queryKey: ['sidebar/search/spotlight'], + }); + + if (isUnread) { + await readMessages({ rid, readThreads: true }); + return; + } + + if (subscription == null) { + return; + } + + LegacyRoomManager.close(subscription.t + subscription.name); + + router.navigate('/home'); + + await unreadMessages(undefined, rid); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return handleToggleRead; +}; diff --git a/apps/meteor/client/hooks/useRoomMenuActions.ts b/apps/meteor/client/hooks/useRoomMenuActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..46308772b4295b05bd5420cb27133804afc058fc --- /dev/null +++ b/apps/meteor/client/hooks/useRoomMenuActions.ts @@ -0,0 +1,118 @@ +import type { RoomType } from '@rocket.chat/core-typings'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { usePermission, useSetting, useUserSubscription } from '@rocket.chat/ui-contexts'; +import type { Fields } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useLeaveRoomAction } from './menuActions/useLeaveRoom'; +import { useToggleFavoriteAction } from './menuActions/useToggleFavoriteAction'; +import { useToggleReadAction } from './menuActions/useToggleReadAction'; +import { useHideRoomAction } from './useHideRoomAction'; +import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; + +const fields: Fields = { + f: true, + t: true, + name: true, +}; + +type RoomMenuActionsProps = { + rid: string; + type: RoomType; + name: string; + isUnread?: boolean; + cl?: boolean; + roomOpen?: boolean; + hideDefaultOptions: boolean; +}; + +export const useRoomMenuActions = ({ + rid, + type, + name, + isUnread, + cl, + roomOpen, + hideDefaultOptions, +}: RoomMenuActionsProps): { title: string; items: GenericMenuItemProps[] }[] => { + const { t } = useTranslation(); + const subscription = useUserSubscription(rid, fields); + + const isFavorite = Boolean(subscription?.f); + const canLeaveChannel = usePermission('leave-c'); + const canLeavePrivate = usePermission('leave-p'); + const canFavorite = useSetting('Favorite_Rooms') as boolean; + + const canLeave = ((): boolean => { + if (type === 'c' && !canLeaveChannel) { + return false; + } + if (type === 'p' && !canLeavePrivate) { + return false; + } + return !((cl != null && !cl) || ['d', 'l'].includes(type)); + })(); + + const handleHide = useHideRoomAction({ rid, type, name }, { redirect: false }); + const handleToggleFavorite = useToggleFavoriteAction({ rid, isFavorite }); + const handleToggleRead = useToggleReadAction({ rid, isUnread, subscription }); + const handleLeave = useLeaveRoomAction({ rid, type, name, roomOpen }); + + const isOmnichannelRoom = type === 'l'; + const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); + + const menuOptions = useMemo( + () => + !hideDefaultOptions + ? [ + !isOmnichannelRoom && { + id: 'hideRoom', + icon: 'eye-off', + content: t('Hide'), + onClick: handleHide, + }, + { + id: 'toggleRead', + icon: 'flag', + content: isUnread ? t('Mark_read') : t('Mark_unread'), + onClick: handleToggleRead, + }, + canFavorite && { + id: 'toggleFavorite', + icon: isFavorite ? 'star-filled' : 'star', + content: isFavorite ? t('Unfavorite') : t('Favorite'), + onClick: handleToggleFavorite, + }, + canLeave && { + id: 'leaveRoom', + icon: 'sign-out', + content: t('Leave_room'), + onClick: handleLeave, + }, + ] + : [], + [ + hideDefaultOptions, + t, + handleHide, + isUnread, + handleToggleRead, + canFavorite, + isFavorite, + handleToggleFavorite, + canLeave, + handleLeave, + isOmnichannelRoom, + ], + ); + + if (isOmnichannelRoom && prioritiesMenu.length > 0) { + return [ + { title: '', items: menuOptions.filter(Boolean) as GenericMenuItemProps[] }, + { title: t('Priorities'), items: prioritiesMenu }, + ]; + } + + return [{ title: '', items: menuOptions.filter(Boolean) as GenericMenuItemProps[] }]; +}; diff --git a/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx index 9fbdc8be019ca6ad3b9983a637b2eb4460e6146b..c628afc34595fa0d9cb533d6640ae9888337de7b 100644 --- a/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx +++ b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx @@ -1,17 +1,15 @@ import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import type { Menu } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import type { ComponentProps } from 'react'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useOmnichannelPriorities } from './useOmnichannelPriorities'; import { dispatchToastMessage } from '../../lib/toast'; -import { PriorityIcon } from '../priorities/PriorityIcon'; +import { PRIORITY_ICONS } from '../priorities/PriorityIcon'; -export const useOmnichannelPrioritiesMenu = (rid: string): ComponentProps<typeof Menu>['options'] | Record<string, never> => { +export const useOmnichannelPrioritiesMenu = (rid: string) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const updateRoomPriority = useEndpoint('POST', '/v1/livechat/room/:rid/priority', { rid }); @@ -32,41 +30,27 @@ export const useOmnichannelPrioritiesMenu = (rid: string): ComponentProps<typeof } }); - const renderOption = useCallback((label: string, weight: LivechatPriorityWeight) => { - return ( - <> - <PriorityIcon level={weight || LivechatPriorityWeight.NOT_SPECIFIED} showUnprioritized /> {label} - </> - ); - }, []); - - return useMemo<ComponentProps<typeof Menu>['options']>(() => { - const menuHeading = { - type: 'heading', - label: t('Priorities'), - }; - + return useMemo(() => { const unprioritizedOption = { - type: 'option', - action: handlePriorityChange(''), - label: { - label: renderOption(t('Unprioritized'), LivechatPriorityWeight.NOT_SPECIFIED), - }, + id: 'unprioritized', + icon: PRIORITY_ICONS[LivechatPriorityWeight.NOT_SPECIFIED].iconName, + iconColor: PRIORITY_ICONS[LivechatPriorityWeight.NOT_SPECIFIED].color, + content: t('Unprioritized'), + onClick: handlePriorityChange(''), }; - const options = priorities.reduce<Record<string, object>>((items, { _id: priorityId, name, i18n, dirty, sortItem }) => { + const options = priorities.map(({ _id: priorityId, name, i18n, dirty, sortItem }) => { const label = dirty && name ? name : i18n; - items[label] = { - action: handlePriorityChange(priorityId), - label: { - label: renderOption(label, sortItem), - }, + return { + id: priorityId, + icon: PRIORITY_ICONS[sortItem].iconName, + iconColor: PRIORITY_ICONS[sortItem].color, + content: label, + onClick: handlePriorityChange(priorityId), }; + }); - return items; - }, {}); - - return priorities.length ? { menuHeading, Unprioritized: unprioritizedOption, ...options } : {}; - }, [t, handlePriorityChange, priorities, renderOption]); + return priorities.length ? [unprioritizedOption, ...options] : []; + }, [t, handlePriorityChange, priorities]); }; diff --git a/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx b/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx index bad31ac8af3f4221e96f984a5c61b6d63487e21e..e41ef88a241504490aa40e203e9f0308e83e874b 100644 --- a/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx +++ b/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx @@ -1,5 +1,5 @@ import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import { Box, Icon, Palette, StatusBullet } from '@rocket.chat/fuselage'; +import { Icon, Palette } from '@rocket.chat/fuselage'; import type { Keys } from '@rocket.chat/icons'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; @@ -13,7 +13,10 @@ type PriorityIconProps = Omit<ComponentProps<typeof Icon>, 'name' | 'color'> & { showUnprioritized?: boolean; }; -const PRIORITY_ICONS: Record<number, { iconName: Keys; color: string }> = { +export const PRIORITY_ICONS: Record<number, { iconName: Keys; color?: string }> = { + [LivechatPriorityWeight.NOT_SPECIFIED]: { + iconName: 'circle-unfilled', + }, [LivechatPriorityWeight.HIGHEST]: { iconName: 'chevron-double-up', color: Palette.badge['badge-background-level-4'].toString(), @@ -51,12 +54,8 @@ export const PriorityIcon = ({ level, size = 20, showUnprioritized = false, ...p return dirty ? name : t(i18n as TranslationKey); }, [level, priorities, t]); - if (showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) { - return ( - <Box is='i' mi='4px' title={t('Unprioritized')}> - <StatusBullet status='offline' /> - </Box> - ); + if (!showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) { + return null; } return iconName ? <Icon {...props} name={iconName} color={color} size={size} title={name} /> : null; diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 6dd5027b95ca0f0c94d1ed49722660e54c4ea769..331a01949aad75d1fa94dc218b828a67260e6102 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -1,7 +1,7 @@ import { IconButton, Sidebar } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { ReactElement, UIEvent } from 'react'; +import type { ReactElement } from 'react'; import { memo, useState } from 'react'; type CondensedProps = { @@ -21,14 +21,10 @@ type CondensedProps = { const Condensed = ({ icon, title = '', avatar, actions, href, unread, menu, badges, ...props }: CondensedProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index 7a09e6d04d14fc8147e62c247791e9c80b94d198..53015d17e036ca19f0fdbc94b85cefb7d3670e64 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -1,7 +1,7 @@ import { Sidebar, IconButton } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { ReactNode, UIEvent } from 'react'; +import type { ReactNode } from 'react'; import { memo, useState } from 'react'; import { useShortTimeAgo } from '../../hooks/useTimeAgo'; @@ -42,15 +42,10 @@ const Extended = ({ }: ExtendedProps) => { const formatDate = useShortTimeAgo(); const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); - const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index 5ee19b1fed6eab546d36adb7dbb49b5b2be6a186..2b9dd8fef490edcd4af08ac428a4c410b6527df5 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -1,6 +1,6 @@ import { Sidebar, IconButton } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; -import type { ReactNode, UIEvent } from 'react'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { ReactNode } from 'react'; import { memo, useState } from 'react'; type MediumProps = { @@ -19,14 +19,10 @@ type MediumProps = { const Medium = ({ icon, title = '', avatar, actions, href, badges, unread, menu, ...props }: MediumProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( diff --git a/apps/meteor/client/sidebar/RoomMenu.spec.tsx b/apps/meteor/client/sidebar/RoomMenu.spec.tsx index a8f2104cea415a88be52a75f01d980f642e3e3a7..2e75a345718a9f512d984b0e8ae6bac8e6dec3da 100644 --- a/apps/meteor/client/sidebar/RoomMenu.spec.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.spec.tsx @@ -48,10 +48,10 @@ it('should display all the menu options for regular rooms', async () => { const menu = screen.queryByRole('button'); await userEvent.click(menu as HTMLElement); - expect(await screen.findByRole('option', { name: 'Hide' })).toBeInTheDocument(); - expect(await screen.findByRole('option', { name: 'Favorite' })).toBeInTheDocument(); - expect(await screen.findByRole('option', { name: 'Mark Unread' })).toBeInTheDocument(); - expect(await screen.findByRole('option', { name: 'Leave' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: 'Hide' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: 'Favorite' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: 'Mark Unread' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: 'Leave' })).toBeInTheDocument(); }); it('should display only mark unread and favorite for omnichannel rooms', async () => { @@ -60,7 +60,7 @@ it('should display only mark unread and favorite for omnichannel rooms', async ( const menu = screen.queryByRole('button'); await userEvent.click(menu as HTMLElement); - expect(await screen.findAllByRole('option')).toHaveLength(2); - expect(screen.queryByRole('option', { name: 'Hide' })).not.toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Leave' })).not.toBeInTheDocument(); + expect(await screen.findAllByRole('menuitem')).toHaveLength(2); + expect(screen.queryByRole('menuitem', { name: 'Hide' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Leave' })).not.toBeInTheDocument(); }); diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index 14e453da54016197892237b6f2f019a003b7b05a..508753263954af972b34bc935deecbe1f2925992 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -1,34 +1,10 @@ import type { RoomType } from '@rocket.chat/core-typings'; -import { Option, Menu } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { TranslationKey, Fields } from '@rocket.chat/ui-contexts'; -import { - useRouter, - useSetModal, - useToastMessageDispatch, - useUserSubscription, - useSetting, - usePermission, - useMethod, - useTranslation, - useEndpoint, -} from '@rocket.chat/ui-contexts'; -import { useQueryClient } from '@tanstack/react-query'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import { memo, useMemo } from 'react'; +import { memo } from 'react'; -import { LegacyRoomManager } from '../../app/ui-utils/client'; -import { UiTextContext } from '../../definition/IRoomTypeConfig'; -import WarningModal from '../components/WarningModal'; -import { useHideRoomAction } from '../hooks/useHideRoomAction'; -import { roomCoordinator } from '../lib/rooms/roomCoordinator'; -import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; - -const fields: Fields = { - f: true, - t: true, - name: true, -}; +import { useRoomMenuActions } from '../hooks/useRoomMenuActions'; type RoomMenuProps = { rid: string; @@ -42,15 +18,6 @@ type RoomMenuProps = { hideDefaultOptions: boolean; }; -const leaveEndpoints = { - p: '/v1/groups.leave', - c: '/v1/channels.leave', - d: '/v1/im.leave', - - v: '/v1/channels.leave', - l: '/v1/groups.leave', -} as const; - const RoomMenu = ({ rid, unread, @@ -63,167 +30,11 @@ const RoomMenu = ({ hideDefaultOptions = false, }: RoomMenuProps): ReactElement | null => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const setModal = useSetModal(); - - const closeModal = useEffectEvent(() => setModal()); - - const router = useRouter(); - - const subscription = useUserSubscription(rid, fields); - const canFavorite = useSetting('Favorite_Rooms'); - const isFavorite = Boolean(subscription?.f); - - const readMessages = useEndpoint('POST', '/v1/subscriptions.read'); - const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); - const leaveRoom = useEndpoint('POST', leaveEndpoints[type]); - - const unreadMessages = useMethod('unreadMessages'); const isUnread = alert || unread || threadUnread; + const sections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions }); - const canLeaveChannel = usePermission('leave-c'); - const canLeavePrivate = usePermission('leave-p'); - - const isOmnichannelRoom = type === 'l'; - const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); - - const queryClient = useQueryClient(); - - const handleHide = useHideRoomAction({ rid, type, name }, { redirect: false }); - - const canLeave = ((): boolean => { - if (type === 'c' && !canLeaveChannel) { - return false; - } - if (type === 'p' && !canLeavePrivate) { - return false; - } - return !((cl != null && !cl) || ['d', 'l'].includes(type)); - })(); - - const handleLeave = useEffectEvent(() => { - const leave = async (): Promise<void> => { - try { - await leaveRoom({ roomId: rid }); - if (roomOpen) { - router.navigate('/home'); - } - LegacyRoomManager.close(rid); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - closeModal(); - }; - - const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING); - - setModal( - <WarningModal - text={t(warnText as TranslationKey, name)} - confirmText={t('Leave_room')} - close={closeModal} - cancelText={t('Cancel')} - confirm={leave} - />, - ); - }); - - const handleToggleRead = useEffectEvent(async () => { - try { - queryClient.invalidateQueries({ - queryKey: ['sidebar/search/spotlight'], - }); - - if (isUnread) { - await readMessages({ rid, readThreads: true }); - return; - } - - if (subscription == null) { - return; - } - - LegacyRoomManager.close(subscription.t + subscription.name); - - router.navigate('/home'); - - await unreadMessages(undefined, rid); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const handleToggleFavorite = useEffectEvent(async () => { - try { - await toggleFavorite({ roomId: rid, favorite: !isFavorite }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const menuOptions = useMemo( - () => ({ - ...(!hideDefaultOptions && { - ...(isOmnichannelRoom - ? {} - : { - hideRoom: { - label: { label: t('Hide'), icon: 'eye-off' }, - action: handleHide, - }, - }), - toggleRead: { - label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, - action: handleToggleRead, - }, - ...(canFavorite - ? { - toggleFavorite: { - label: { - label: isFavorite ? t('Unfavorite') : t('Favorite'), - icon: isFavorite ? 'star-filled' : 'star', - }, - action: handleToggleFavorite, - }, - } - : {}), - ...(canLeave && { - leaveRoom: { - label: { label: t('Leave_room'), icon: 'sign-out' }, - action: handleLeave, - }, - }), - }), - ...(isOmnichannelRoom && prioritiesMenu), - }), - [ - hideDefaultOptions, - t, - handleHide, - isUnread, - handleToggleRead, - canFavorite, - isFavorite, - handleToggleFavorite, - canLeave, - handleLeave, - isOmnichannelRoom, - prioritiesMenu, - ], - ); - - return ( - <Menu - rcx-sidebar-item__menu - title={t('Options')} - mini - aria-keyshortcuts='alt' - options={menuOptions} - maxHeight={300} - renderItem={({ label: { label, icon }, ...props }): JSX.Element => <Option label={label} icon={icon} {...props} />} - /> - ); + return <GenericMenu detached className='rcx-sidebar-item__menu' title={t('Options')} mini aria-keyshortcuts='alt' sections={sections} />; }; export default memo(RoomMenu); diff --git a/apps/meteor/client/sidebarv2/Item/Condensed.tsx b/apps/meteor/client/sidebarv2/Item/Condensed.tsx index ffdf0eb2080781f7ca8f6efbd269a20b474f5819..9c4edf7159e1bbf13fefb1b76aacade15a3309b3 100644 --- a/apps/meteor/client/sidebarv2/Item/Condensed.tsx +++ b/apps/meteor/client/sidebarv2/Item/Condensed.tsx @@ -1,7 +1,7 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { HTMLAttributes, ReactElement, UIEvent } from 'react'; +import type { HTMLAttributes, ReactElement } from 'react'; import { memo, useState } from 'react'; type CondensedProps = { @@ -21,14 +21,10 @@ type CondensedProps = { const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLDivElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( diff --git a/apps/meteor/client/sidebarv2/Item/Extended.tsx b/apps/meteor/client/sidebarv2/Item/Extended.tsx index 12d999c5a3d97a48a9b129fbe4986d8c0f2449a5..9a2f49c711acb7bdd6de2294301b38a973aaecbd 100644 --- a/apps/meteor/client/sidebarv2/Item/Extended.tsx +++ b/apps/meteor/client/sidebarv2/Item/Extended.tsx @@ -9,9 +9,9 @@ import { SidebarV2ItemMenu, IconButton, } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { HTMLAttributes, ReactNode, UIEvent } from 'react'; +import type { HTMLAttributes, ReactNode } from 'react'; import { memo, useState } from 'react'; import { useShortTimeAgo } from '../../hooks/useTimeAgo'; @@ -52,27 +52,21 @@ const Extended = ({ }: ExtendedProps) => { const formatDate = useShortTimeAgo(); const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLDivElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( <SidebarV2Item href={href} selected={selected} {...props}> {avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>} - <SidebarV2ItemCol> <SidebarV2ItemRow> {icon && icon} <SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle> {time && <SidebarV2ItemTimestamp>{formatDate(time)}</SidebarV2ItemTimestamp>} </SidebarV2ItemRow> - <SidebarV2ItemRow> <SidebarV2ItemContent unread={unread}>{subtitle}</SidebarV2ItemContent> {badges && badges} diff --git a/apps/meteor/client/sidebarv2/Item/Medium.tsx b/apps/meteor/client/sidebarv2/Item/Medium.tsx index 0b4e04c031de4b0e11effc27022a5c6522456411..d4fbde625b77b2829a497f4ae0f12e4554927a14 100644 --- a/apps/meteor/client/sidebarv2/Item/Medium.tsx +++ b/apps/meteor/client/sidebarv2/Item/Medium.tsx @@ -1,7 +1,7 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage'; -import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { HTMLAttributes, ReactNode, UIEvent } from 'react'; +import type { HTMLAttributes, ReactNode } from 'react'; import { memo, useState } from 'react'; type MediumProps = { @@ -20,14 +20,10 @@ type MediumProps = { const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - const isReduceMotionEnabled = usePrefersReducedMotion(); - const handleMenu = useEffectEvent((e: UIEvent<HTMLDivElement>) => { - setMenuVisibility(e.currentTarget.offsetWidth > 0 && Boolean(menu)); - }); const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setMenuVisibility, }; return ( diff --git a/apps/meteor/client/sidebarv2/RoomMenu.tsx b/apps/meteor/client/sidebarv2/RoomMenu.tsx index f74a231c613f53ae6ab81af3ac70e1a7f5659749..fa1f236edb9fe854e679c76fba5e8b5cbab843dc 100644 --- a/apps/meteor/client/sidebarv2/RoomMenu.tsx +++ b/apps/meteor/client/sidebarv2/RoomMenu.tsx @@ -1,34 +1,9 @@ import type { RoomType } from '@rocket.chat/core-typings'; -import { Option, Menu } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { TranslationKey, Fields } from '@rocket.chat/ui-contexts'; -import { - useRouter, - useSetModal, - useToastMessageDispatch, - useUserSubscription, - useSetting, - usePermission, - useMethod, - useTranslation, - useEndpoint, -} from '@rocket.chat/ui-contexts'; -import { useQueryClient } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; -import { memo, useMemo } from 'react'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { memo } from 'react'; -import { LegacyRoomManager } from '../../app/ui-utils/client'; -import { UiTextContext } from '../../definition/IRoomTypeConfig'; -import WarningModal from '../components/WarningModal'; -import { useHideRoomAction } from '../hooks/useHideRoomAction'; -import { roomCoordinator } from '../lib/rooms/roomCoordinator'; -import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; - -const fields: Fields = { - f: true, - t: true, - name: true, -}; +import { useRoomMenuActions } from '../hooks/useRoomMenuActions'; type RoomMenuProps = { rid: string; @@ -42,184 +17,13 @@ type RoomMenuProps = { hideDefaultOptions: boolean; }; -const leaveEndpoints = { - p: '/v1/groups.leave', - c: '/v1/channels.leave', - d: '/v1/im.leave', - - v: '/v1/channels.leave', - l: '/v1/groups.leave', -} as const; - -const RoomMenu = ({ - rid, - unread, - threadUnread, - alert, - roomOpen, - type, - cl, - name = '', - hideDefaultOptions = false, -}: RoomMenuProps): ReactElement | null => { +const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '', hideDefaultOptions = false }: RoomMenuProps) => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const setModal = useSetModal(); - - const closeModal = useEffectEvent(() => setModal()); - - const router = useRouter(); - - const subscription = useUserSubscription(rid, fields); - const canFavorite = useSetting('Favorite_Rooms'); - const isFavorite = Boolean(subscription?.f); - - const readMessages = useEndpoint('POST', '/v1/subscriptions.read'); - const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); - const leaveRoom = useEndpoint('POST', leaveEndpoints[type]); - - const unreadMessages = useMethod('unreadMessages'); const isUnread = alert || unread || threadUnread; + const sections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions }); - const canLeaveChannel = usePermission('leave-c'); - const canLeavePrivate = usePermission('leave-p'); - - const isOmnichannelRoom = type === 'l'; - const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); - - const queryClient = useQueryClient(); - - const handleHide = useHideRoomAction({ rid, type, name }, { redirect: false }); - - const canLeave = ((): boolean => { - if (type === 'c' && !canLeaveChannel) { - return false; - } - if (type === 'p' && !canLeavePrivate) { - return false; - } - return !((cl != null && !cl) || ['d', 'l'].includes(type)); - })(); - - const handleLeave = useEffectEvent(() => { - const leave = async (): Promise<void> => { - try { - await leaveRoom({ roomId: rid }); - if (roomOpen) { - router.navigate('/home'); - } - LegacyRoomManager.close(rid); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - closeModal(); - }; - - const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING); - - setModal( - <WarningModal - text={t(warnText as TranslationKey, name)} - confirmText={t('Leave_room')} - close={closeModal} - cancelText={t('Cancel')} - confirm={leave} - />, - ); - }); - - const handleToggleRead = useEffectEvent(async () => { - try { - queryClient.invalidateQueries({ - queryKey: ['sidebar/search/spotlight'], - }); - - if (isUnread) { - await readMessages({ rid, readThreads: true }); - return; - } - - if (subscription == null) { - return; - } - - LegacyRoomManager.close(subscription.t + subscription.name); - - router.navigate('/home'); - - await unreadMessages(undefined, rid); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const handleToggleFavorite = useEffectEvent(async () => { - try { - await toggleFavorite({ roomId: rid, favorite: !isFavorite }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const menuOptions = useMemo( - () => ({ - ...(!hideDefaultOptions && { - hideRoom: { - label: { label: t('Hide'), icon: 'eye-off' }, - action: handleHide, - }, - toggleRead: { - label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, - action: handleToggleRead, - }, - ...(canFavorite - ? { - toggleFavorite: { - label: { - label: isFavorite ? t('Unfavorite') : t('Favorite'), - icon: isFavorite ? 'star-filled' : 'star', - }, - action: handleToggleFavorite, - }, - } - : {}), - ...(canLeave && { - leaveRoom: { - label: { label: t('Leave_room'), icon: 'sign-out' }, - action: handleLeave, - }, - }), - }), - ...(isOmnichannelRoom && prioritiesMenu), - }), - [ - hideDefaultOptions, - t, - handleHide, - isUnread, - handleToggleRead, - canFavorite, - isFavorite, - handleToggleFavorite, - canLeave, - handleLeave, - isOmnichannelRoom, - prioritiesMenu, - ], - ); - - return ( - <Menu - rcx-sidebar-item__menu - title={t('Options')} - mini - aria-keyshortcuts='alt' - options={menuOptions} - maxHeight={300} - renderItem={({ label: { label, icon }, ...props }): JSX.Element => <Option label={label} icon={icon} {...props} />} - /> - ); + return <GenericMenu detached className='rcx-sidebar-item__menu' title={t('Options')} mini aria-keyshortcuts='alt' sections={sections} />; }; export default memo(RoomMenu); diff --git a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx index 49179f12c732d31e05d9b74b1a1a7155c5812c02..ee6c1a505e0af3a60de4bd1939afdee58850bf6c 100644 --- a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx +++ b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx @@ -11,6 +11,7 @@ type ActionConfirmModalProps = { onCancel: () => void; }; +// TODO: Use react-hook-form const ActionConfirmModal = ({ isPassword, onConfirm, onCancel }: ActionConfirmModalProps) => { const { t } = useTranslation(); const [inputText, setInputText] = useState(''); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 46f806104420fe9c1c9f55a53c5869c8008a57cf..5c8d83a9d1ff091071da56e48bc3826ac050f891 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -79,14 +79,14 @@ export class HomeSidenav { const sidebarItem = this.getSidebarItemByName(name); await sidebarItem.focus(); await sidebarItem.locator('.rcx-sidebar-item__menu').click(); - await this.page.getByRole('option', { name: 'Mark Unread' }).click(); + await this.page.getByRole('menuitem', { name: 'Mark Unread' }).click(); } async selectPriority(name: string, priority: string) { const sidebarItem = this.getSidebarItemByName(name); await sidebarItem.focus(); await sidebarItem.locator('.rcx-sidebar-item__menu').click(); - await this.page.locator(`li[value="${priority}"]`).click(); + await this.page.getByRole('menuitem', { name: priority }).click(); } async openAdministrationByLabel(text: string): Promise<void> { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts index a34a1af480a5f598d00d3c9646004ef78f287924..9cc548438a7334585b6357a450cbbf1d09dec4f6 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts @@ -79,7 +79,7 @@ export class Sidebar { await item.hover(); await item.focus(); await item.locator('.rcx-sidebar-item__menu').click(); - await this.page.getByRole('option', { name: 'Mark Unread' }).click(); + await this.page.getByRole('menuitem', { name: 'Mark Unread' }).click(); } getCollapseGroupByName(name: string): Locator { diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx index 41576a2a40a648991ab401ffa24cbe83fbfbccd7..dcf6043d5a23c35772b1c6f5ad5c1da6b10208b9 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx @@ -4,6 +4,7 @@ import type { ComponentProps, MouseEvent, ReactNode } from 'react'; export type GenericMenuItemProps = { id: string; icon?: ComponentProps<typeof MenuItemIcon>['name']; + iconColor?: ComponentProps<typeof MenuItemIcon>['color']; content?: ReactNode; addon?: ReactNode; onClick?: (e?: MouseEvent<HTMLElement>) => void; @@ -15,10 +16,10 @@ export type GenericMenuItemProps = { variant?: string; }; -const GenericMenuItem = ({ icon, content, addon, status, gap, tooltip }: GenericMenuItemProps) => ( +const GenericMenuItem = ({ icon, iconColor, content, addon, status, gap, tooltip }: GenericMenuItemProps) => ( <> {gap && <MenuItemColumn />} - {icon && <MenuItemIcon name={icon} />} + {icon && <MenuItemIcon name={icon} color={iconColor} />} {status && <MenuItemColumn>{status}</MenuItemColumn>} {content && <MenuItemContent title={tooltip}>{content}</MenuItemContent>} {addon && <MenuItemInput>{addon}</MenuItemInput>}