feat: Hide UI elements through window postmessage (#31184)

pull/31345/head^2
gabriellsh 2 years ago committed by GitHub
parent 2fa8055d06
commit b2b0035162
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .changeset/big-teachers-change.md
  2. 3
      apps/meteor/app/ui-utils/client/lib/messageBox.ts
  3. 11
      apps/meteor/client/components/message/toolbox/MessageToolbox.tsx
  4. 2
      apps/meteor/client/hooks/useAppActionButtons.ts
  5. 23
      apps/meteor/client/hooks/useFileInput.ts
  6. 23
      apps/meteor/client/providers/LayoutProvider.tsx
  7. 2
      apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx
  8. 10
      apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
  9. 96
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/ActionsToolbarDropdown.tsx
  10. 93
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx
  11. 75
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx
  12. 10
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/ToolbarAction.ts
  13. 39
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useAudioMessageAction.ts
  14. 24
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useCreateDiscussionAction.tsx
  15. 52
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts
  16. 27
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx
  17. 112
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useToolbarActions.ts
  18. 45
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useVideoMessageAction.ts
  19. 48
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx
  20. 6
      apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts
  21. 5
      apps/meteor/client/views/room/providers/RoomToolboxProvider.tsx
  22. 2
      apps/meteor/tests/e2e/page-objects/fragments/home-content.ts
  23. 12
      packages/ui-contexts/src/LayoutContext.ts
  24. 6
      packages/ui-contexts/src/hooks/useLayoutHiddenActions.ts
  25. 1
      packages/ui-contexts/src/index.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/ui-contexts": minor
---
Add the possibility to hide some elements through postMessage events.

@ -1,4 +1,5 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI';
@ -6,7 +7,7 @@ import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI';
export type MessageBoxAction = {
label: TranslationKey;
id: string;
icon?: string;
icon: IconName;
action: (params: { rid: IRoom['_id']; tmid?: IMessage['_id']; event: Event; chat: ChatAPI }) => void;
condition?: () => boolean;
};

@ -2,7 +2,7 @@ import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket
import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings';
import { MessageToolbox as FuselageMessageToolbox, MessageToolboxItem } from '@rocket.chat/fuselage';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useUser, useSettings, useTranslation, useMethod } from '@rocket.chat/ui-contexts';
import { useUser, useSettings, useTranslation, useMethod, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import React, { memo, useMemo } from 'react';
@ -70,13 +70,18 @@ const MessageToolbox = ({
const actionButtonApps = useMessageActionAppsActionButtons(context);
const { messageToolbox: hiddenActions } = useLayoutHiddenActions();
const actionsQueryResult = useQuery(['rooms', room._id, 'messages', message._id, 'actions'] as const, async () => {
const props = { message, room, user, subscription, settings: mapSettings, chat };
const toolboxItems = await MessageAction.getAll(props, context, 'message');
const menuItems = await MessageAction.getAll(props, context, 'menu');
return { message: toolboxItems, menu: menuItems };
return {
message: toolboxItems.filter((action) => !hiddenActions.includes(action.id)),
menu: menuItems.filter((action) => !hiddenActions.includes(action.id)),
};
});
const toolbox = useRoomToolbox();
@ -85,7 +90,7 @@ const MessageToolbox = ({
const autoTranslateOptions = useAutoTranslate(subscription);
if (selecting) {
if (selecting || (!actionsQueryResult.data?.message.length && !actionsQueryResult.data?.menu.length)) {
return null;
}

@ -76,7 +76,7 @@ export const useMessageboxAppsActionButtons = () => {
return applyButtonFilters(action);
})
.map((action) => {
const item: MessageBoxAction = {
const item: Omit<MessageBoxAction, 'icon'> = {
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
action: (params) => {

@ -0,0 +1,23 @@
import { useRef, useEffect } from 'react';
import type { AllHTMLAttributes } from 'react';
export const useFileInput = (props: AllHTMLAttributes<HTMLInputElement>) => {
const ref = useRef<HTMLInputElement>();
useEffect(() => {
const fileInput = document.createElement('input');
fileInput.setAttribute('style', 'display: none;');
Object.entries(props).forEach(([key, value]) => {
fileInput.setAttribute(key, value);
});
document.body.appendChild(fileInput);
ref.current = fileInput;
return (): void => {
ref.current = undefined;
fileInput.remove();
};
}, [props]);
return ref;
};

@ -3,10 +3,18 @@ import { LayoutContext, useRouter, useSetting } from '@rocket.chat/ui-contexts';
import type { FC } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
const hiddenActionsDefaultValue = {
roomToolbox: [],
messageToolbox: [],
composerToolbox: [],
userToolbox: [],
};
const LayoutProvider: FC = ({ children }) => {
const showTopNavbarEmbeddedLayout = Boolean(useSetting('UI_Show_top_navbar_embedded_layout'));
const [isCollapsed, setIsCollapsed] = useState(false);
const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"]
const [hiddenActions, setHiddenActions] = useState(hiddenActionsDefaultValue);
const router = useRouter();
// Once the layout is embedded, it can't be changed
@ -18,6 +26,18 @@ const LayoutProvider: FC = ({ children }) => {
setIsCollapsed(isMobile);
}, [isMobile]);
useEffect(() => {
const eventHandler = (event: MessageEvent<any>) => {
if (event.data?.event !== 'overrideUi') {
return;
}
setHiddenActions({ ...hiddenActionsDefaultValue, ...event.data.hideActions });
};
window.addEventListener('message', eventHandler);
return () => window.removeEventListener('message', eventHandler);
}, []);
return (
<LayoutContext.Provider
children={children}
@ -42,8 +62,9 @@ const LayoutProvider: FC = ({ children }) => {
contextualBarExpanded: breakpoints.includes('sm'),
// eslint-disable-next-line no-nested-ternary
contextualBarPosition: breakpoints.includes('sm') ? (breakpoints.includes('lg') ? 'relative' : 'absolute') : 'fixed',
hiddenActions,
}),
[isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router],
[isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router, hiddenActions],
)}
/>
);

@ -91,7 +91,7 @@ const RoomToolbox = ({ className }: RoomToolboxProps) => {
{featuredActions.map(mapToToolboxItem)}
{featuredActions.length > 0 && <HeaderToolboxDivider />}
{visibleActions.map(mapToToolboxItem)}
{(normalActions.length > 6 || !roomToolboxExpanded) && (
{(normalActions.length > 6 || !roomToolboxExpanded) && !!hiddenActions.length && (
<GenericMenu title={t('Options')} data-qa-id='ToolBox-Menu' sections={hiddenActions} placement='bottom-end' />
)}
</>

@ -1,3 +1,4 @@
/* eslint-disable complexity */
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { Button, Tag, Box } from '@rocket.chat/fuselage';
import { useContentBoxSize, useMutableCallback } from '@rocket.chat/fuselage-hooks';
@ -410,15 +411,14 @@ const MessageBox = ({
disabled={isRecording || !canSend}
/>
)}
<MessageComposerActionsDivider />
<MessageBoxActionsToolbar
variant={sizes.inlineSize < 480 ? 'small' : 'large'}
isRecording={isRecording}
typing={typing}
canSend={canSend}
typing={typing}
isMicrophoneDenied={isMicrophoneDenied}
rid={room._id}
tmid={tmid}
isMicrophoneDenied={isMicrophoneDenied}
isRecording={isRecording}
variant={sizes.inlineSize < 480 ? 'small' : 'large'}
/>
</MessageComposerToolbarActions>
<MessageComposerToolbarSubmit>

@ -1,106 +1,26 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Dropdown, IconButton, Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { useTranslation, useUserRoom } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ReactNode } from 'react';
import React, { useRef, Fragment } from 'react';
import { Dropdown, IconButton } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import React, { useRef } from 'react';
import { messageBox } from '../../../../../../app/ui-utils/client';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useAppActionButtons';
import type { ChatAPI } from '../../../../../lib/chats/ChatAPI';
import { useDropdownVisibility } from '../../../../../sidebar/header/hooks/useDropdownVisibility';
import { useChat } from '../../../contexts/ChatContext';
import CreateDiscussionAction from './actions/CreateDiscussionAction';
import ShareLocationAction from './actions/ShareLocationAction';
import WebdavAction from './actions/WebdavAction';
type ActionsToolbarDropdownProps = {
chatContext?: ChatAPI;
rid: IRoom['_id'];
isRecording?: boolean;
tmid?: string;
actions?: ReactNode[];
disabled?: boolean;
children: () => ReactNode[];
};
const ActionsToolbarDropdown = ({ isRecording, rid, tmid, actions, ...props }: ActionsToolbarDropdownProps) => {
const chatContext = useChat();
if (!chatContext) {
throw new Error('useChat must be used within a ChatProvider');
}
const t = useTranslation();
const ActionsToolbarDropdown = ({ children, ...props }: ActionsToolbarDropdownProps) => {
const reference = useRef(null);
const target = useRef(null);
const room = useUserRoom(rid);
const { isVisible, toggle } = useDropdownVisibility({ reference, target });
const apps = useMessageboxAppsActionButtons();
const groups = {
...(apps.isSuccess &&
apps.data.length > 0 && {
Apps: apps.data,
}),
...messageBox.actions.get(),
};
const messageBoxActions = Object.entries(groups).map(([name, group]) => {
const items = group.map((item) => ({
icon: item.icon,
name: t(item.label),
type: 'messagebox-action',
id: item.id,
action: item.action,
}));
return {
title: t.has(name) && t(name),
items,
};
});
return (
<>
<IconButton
data-qa-id='menu-more-actions'
disabled={isRecording}
small
ref={reference}
icon='plus'
onClick={() => toggle()}
{...props}
/>
<IconButton data-qa-id='menu-more-actions' small ref={reference} icon='plus' onClick={() => toggle()} {...props} />
{isVisible && (
<Dropdown reference={reference} ref={target} placement='bottom-start'>
<OptionTitle>{t('Create_new')}</OptionTitle>
{room && <CreateDiscussionAction room={room} />}
{actions}
<WebdavAction chatContext={chatContext} />
{room && <ShareLocationAction room={room} tmid={tmid} />}
{messageBoxActions?.map((actionGroup, index) => (
<Fragment key={index}>
<OptionTitle>{actionGroup.title}</OptionTitle>
{actionGroup.items.map((item) => (
<Option
key={item.id}
onClick={(event) =>
item.action({
rid,
tmid,
event: event as unknown as Event,
chat: chatContext,
})
}
gap={!item.icon}
>
{item.icon && <OptionIcon name={item.icon as ComponentProps<typeof OptionIcon>['name']} />}
<OptionContent>{item.name}</OptionContent>
</Option>
))}
</Fragment>
))}
{children()}
</Dropdown>
)}
</>

@ -1,52 +1,91 @@
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import { Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import React, { memo } from 'react';
import { useChat } from '../../../contexts/ChatContext';
import ActionsToolbarDropdown from './ActionsToolbarDropdown';
import AudioMessageAction from './actions/AudioMessageAction';
import FileUploadAction from './actions/FileUploadAction';
import VideoMessageAction from './actions/VideoMessageAction';
import { useToolbarActions } from './hooks/useToolbarActions';
type MessageBoxActionsToolbarProps = {
canSend: boolean;
typing: boolean;
isMicrophoneDenied: boolean;
variant: 'small' | 'large';
isRecording: boolean;
typing: boolean;
canSend: boolean;
rid: IRoom['_id'];
tmid?: IMessage['_id'];
isMicrophoneDenied?: boolean;
};
const MessageBoxActionsToolbar = ({
variant = 'large',
isRecording,
typing,
canSend,
typing,
isRecording,
rid,
tmid,
variant = 'large',
isMicrophoneDenied,
...props
}: MessageBoxActionsToolbarProps) => {
const actions = [
<VideoMessageAction key='video' collapsed={variant === 'small'} disabled={!canSend || typing || isRecording} {...props} />,
<AudioMessageAction
key='audio'
disabled={!canSend || typing || isRecording || isMicrophoneDenied}
isMicrophoneDenied={isMicrophoneDenied}
{...props}
/>,
<FileUploadAction key='file' collapsed={variant === 'small'} disabled={!canSend || isRecording} {...props} />,
];
let featuredAction;
if (variant === 'small') {
featuredAction = actions.splice(1, 1);
const data = useToolbarActions({
canSend,
typing,
isRecording,
isMicrophoneDenied: Boolean(isMicrophoneDenied),
rid,
tmid,
variant,
});
const { featured, menu } = data;
const t = useTranslation();
const chatContext = useChat();
if (!chatContext) {
throw new Error('useChat must be used within a ChatProvider');
}
if (!featured.length && !menu.length) {
return null;
}
return (
<>
{variant !== 'small' && actions}
{variant === 'small' && featuredAction}
<ActionsToolbarDropdown {...(variant === 'small' && { actions })} isRecording={isRecording} rid={rid} tmid={tmid} {...props} />
<MessageComposerActionsDivider />
{featured.map((action) => (
<MessageComposerAction key={action.id} {...action} data-qa-id={action.id} icon={action.icon} />
))}
{menu.length > 0 && (
<ActionsToolbarDropdown disabled={isRecording}>
{() =>
menu.map((option) => {
if (typeof option === 'string') {
return <OptionTitle key={option}>{t.has(option) ? t(option) : option}</OptionTitle>;
}
return (
<Option
key={option.id}
onClick={(event) =>
option.onClick({
rid,
tmid,
event: event as unknown as Event,
chat: chatContext,
})
}
gap={!option.icon}
disabled={option.disabled}
>
{option.icon && <OptionIcon name={option.icon as ComponentProps<typeof OptionIcon>['name']} />}
<OptionContent>{option.label}</OptionContent>
</Option>
);
})
}
</ActionsToolbarDropdown>
)}
</>
);
};

@ -1,75 +0,0 @@
import { Option, OptionContent, OptionIcon } from '@rocket.chat/fuselage';
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, AllHTMLAttributes } from 'react';
import React, { useRef } from 'react';
import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI';
import { useChat } from '../../../../contexts/ChatContext';
type FileUploadActionProps = {
collapsed?: boolean;
chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const FileUploadAction = ({ collapsed, chatContext, disabled, ...props }: FileUploadActionProps) => {
const t = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled');
const fileInputRef = useRef<HTMLInputElement>(null);
const chat = useChat() ?? chatContext;
const resetFileInput = () => {
if (!fileInputRef.current) {
return;
}
fileInputRef.current.value = '';
};
const handleUploadChange = async (e: ChangeEvent<HTMLInputElement>) => {
const { mime } = await import('../../../../../../../app/utils/lib/mimeTypes');
const filesToUpload = Array.from(e.target.files ?? []).map((file) => {
Object.defineProperty(file, 'type', {
value: mime.lookup(file.name),
});
return file;
});
chat?.flows.uploadFiles(filesToUpload, resetFileInput);
};
const handleUpload = () => {
fileInputRef.current?.click();
};
if (collapsed) {
return (
<>
<Option
{...((!fileUploadEnabled || disabled) && { title: t('Not_Available') })}
disabled={!fileUploadEnabled || disabled}
onClick={handleUpload}
>
<OptionIcon name='clip' />
<OptionContent>{t('File')}</OptionContent>
</Option>
<input ref={fileInputRef} type='file' onChange={handleUploadChange} multiple style={{ display: 'none' }} />
</>
);
}
return (
<>
<MessageComposerAction
data-qa-id='file-upload'
icon='clip'
disabled={!fileUploadEnabled || disabled}
onClick={handleUpload}
title={t('File')}
{...props}
/>
<input ref={fileInputRef} type='file' onChange={handleUploadChange} multiple style={{ display: 'none' }} />
</>
);
};
export default FileUploadAction;

@ -0,0 +1,10 @@
import type { Keys as IconName } from '@rocket.chat/icons';
export type ToolbarAction = {
title?: string;
disabled?: boolean;
onClick: (...params: any) => unknown;
icon: IconName;
label: string;
id: string;
};

@ -1,28 +1,22 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo } from 'react';
import { AudioRecorder } from '../../../../../../../app/ui/client/lib/recorderjs/AudioRecorder';
import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
import type { ToolbarAction } from './ToolbarAction';
const audioRecorder = new AudioRecorder();
type AudioMessageActionProps = {
chatContext?: ChatAPI;
isMicrophoneDenied?: boolean;
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...props }: AudioMessageActionProps) => {
export const useAudioMessageAction = (disabled: boolean, isMicrophoneDenied: boolean): ToolbarAction => {
const isFileUploadEnabled = useSetting('FileUpload_Enabled') as boolean;
const isAudioRecorderEnabled = useSetting('Message_AudioRecorderEnabled') as boolean;
const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList') as string;
const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList') as string;
const [isPermissionDenied] = useMediaPermissions('microphone');
const t = useTranslation();
const isAllowed = useMemo(
() =>
@ -39,7 +33,7 @@ const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...prop
const getMediaActionTitle = useMediaActionTitle('audio', isPermissionDenied, isFileUploadEnabled, isAudioRecorderEnabled, isAllowed);
const chat = useChat() ?? chatContext;
const chat = useChat();
const stopRecording = useMutableCallback(() => {
chat?.action.stop('recording');
@ -61,17 +55,12 @@ const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...prop
const handleRecordButtonClick = () => chat?.composer?.setRecordingMode(true);
return (
<MessageComposerAction
title={getMediaActionTitle}
icon='mic'
disabled={disabled || !isAllowed}
className='rc-message-box__icon rc-message-box__audio-message-mic'
data-qa-id='audio-record'
onClick={handleRecordButtonClick}
{...props}
/>
);
return {
id: 'audio-message',
title: getMediaActionTitle,
disabled: !isAllowed || Boolean(disabled),
onClick: handleRecordButtonClick,
icon: 'mic',
label: t('Audio_message'),
};
};
export default AudioMessageAction;

@ -1,12 +1,16 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { useTranslation, useSetting, usePermission, useSetModal } from '@rocket.chat/ui-contexts';
import React from 'react';
import CreateDiscussion from '../../../../../../components/CreateDiscussion';
import type { ToolbarAction } from './ToolbarAction';
export const useCreateDiscussionAction = (room?: IRoom): ToolbarAction => {
if (!room) {
throw new Error('Invalid room');
}
const CreateDiscussionAction = ({ room }: { room: IRoom }) => {
const setModal = useSetModal();
const t = useTranslation();
@ -19,12 +23,12 @@ const CreateDiscussionAction = ({ room }: { room: IRoom }) => {
const allowDiscussion = room && discussionEnabled && !isRoomFederated(room) && (canStartDiscussion || canSstartDiscussionOtherUser);
return (
<Option {...(!allowDiscussion && { title: t('Not_Available') })} disabled={!allowDiscussion} onClick={handleCreateDiscussion}>
<OptionIcon name='discussion' />
<OptionContent>{t('Discussion')}</OptionContent>
</Option>
);
return {
id: 'create-discussion',
title: !allowDiscussion ? t('Not_Available') : undefined,
disabled: !allowDiscussion,
onClick: handleCreateDiscussion,
icon: 'discussion',
label: t('Discussion'),
};
};
export default CreateDiscussionAction;

@ -0,0 +1,52 @@
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useFileInput } from '../../../../../../hooks/useFileInput';
import { useChat } from '../../../../contexts/ChatContext';
import type { ToolbarAction } from './ToolbarAction';
const fileInputProps = { type: 'file', multiple: true };
export const useFileUploadAction = (disabled: boolean): ToolbarAction => {
const t = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled');
const fileInputRef = useFileInput(fileInputProps);
const chat = useChat();
useEffect(() => {
const resetFileInput = () => {
if (!fileInputRef?.current) {
return;
}
fileInputRef.current.value = '';
};
const handleUploadChange = async () => {
const { mime } = await import('../../../../../../../app/utils/lib/mimeTypes');
const filesToUpload = Array.from(fileInputRef?.current?.files ?? []).map((file) => {
Object.defineProperty(file, 'type', {
value: mime.lookup(file.name),
});
return file;
});
chat?.flows.uploadFiles(filesToUpload, resetFileInput);
};
fileInputRef.current?.addEventListener('change', handleUploadChange);
return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange);
}, [chat, fileInputRef]);
const handleUpload = () => {
fileInputRef?.current?.click();
};
return {
id: 'file-upload',
icon: 'clip',
label: t('File'),
title: t('File'),
onClick: handleUpload,
disabled: !fileUploadEnabled || disabled,
};
};

@ -1,12 +1,16 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { useSetting, useSetModal, useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal';
import type { ToolbarAction } from './ToolbarAction';
export const useShareLocationAction = (room?: IRoom, tmid?: string): ToolbarAction => {
if (!room) {
throw new Error('Invalid room');
}
const ShareLocationAction = ({ room, tmid }: { room: IRoom; tmid?: string }) => {
const t = useTranslation();
const setModal = useSetModal();
@ -19,15 +23,12 @@ const ShareLocationAction = ({ room, tmid }: { room: IRoom; tmid?: string }) =>
const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room);
return (
<>
<OptionTitle>{t('Share')}</OptionTitle>
<Option {...(!allowGeolocation && { title: t('Not_Available') })} disabled={!allowGeolocation} onClick={handleShareLocation}>
<OptionIcon name='map-pin' />
<OptionContent>{t('Location')}</OptionContent>
</Option>
</>
);
return {
id: 'share-location',
icon: 'map-pin',
label: t('Location'),
title: !allowGeolocation ? t('Not_Available') : undefined,
onClick: handleShareLocation,
disabled: !allowGeolocation,
};
};
export default ShareLocationAction;

@ -0,0 +1,112 @@
import { useUserRoom, useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import { messageBox } from '../../../../../../../app/ui-utils/client';
import { isTruthy } from '../../../../../../../lib/isTruthy';
import { useMessageboxAppsActionButtons } from '../../../../../../hooks/useAppActionButtons';
import type { ToolbarAction } from './ToolbarAction';
import { useAudioMessageAction } from './useAudioMessageAction';
import { useCreateDiscussionAction } from './useCreateDiscussionAction';
import { useFileUploadAction } from './useFileUploadAction';
import { useShareLocationAction } from './useShareLocationAction';
import { useVideoMessageAction } from './useVideoMessageAction';
import { useWebdavActions } from './useWebdavActions';
type ToolbarActionsOptions = {
variant: 'small' | 'large';
canSend: boolean;
typing: boolean;
isRecording: boolean;
isMicrophoneDenied: boolean;
rid: string;
tmid?: string;
};
const isHidden = (hiddenActions: Array<string>, action: ToolbarAction) => {
if (!action) {
return true;
}
return hiddenActions.includes(action.id);
};
export const useToolbarActions = ({ canSend, typing, isRecording, isMicrophoneDenied, rid, tmid, variant }: ToolbarActionsOptions) => {
const room = useUserRoom(rid);
const t = useTranslation();
const videoMessageAction = useVideoMessageAction(!canSend || typing || isRecording);
const audioMessageAction = useAudioMessageAction(!canSend || typing || isRecording || isMicrophoneDenied, isMicrophoneDenied);
const fileUploadAction = useFileUploadAction(!canSend || typing || isRecording);
const webdavActions = useWebdavActions();
const createDiscussionAction = useCreateDiscussionAction(room);
const shareLocationAction = useShareLocationAction(room, tmid);
const apps = useMessageboxAppsActionButtons();
const { composerToolbox: hiddenActions } = useLayoutHiddenActions();
const allActions = {
...(!isHidden(hiddenActions, videoMessageAction) && { videoMessageAction }),
...(!isHidden(hiddenActions, audioMessageAction) && { audioMessageAction }),
...(!isHidden(hiddenActions, fileUploadAction) && { fileUploadAction }),
...(!isHidden(hiddenActions, createDiscussionAction) && { createDiscussionAction }),
...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }),
...(!hiddenActions.includes('webdav-add') && { webdavActions }),
};
const data: { featured: ToolbarAction[]; menu: Array<string | ToolbarAction> } = (() => {
const featured: Array<ToolbarAction | undefined> = [];
const createNew = [];
const share = [];
if (variant === 'small') {
featured.push(allActions.audioMessageAction);
createNew.push(allActions.videoMessageAction, allActions.fileUploadAction);
} else {
featured.push(allActions.videoMessageAction, allActions.audioMessageAction, allActions.fileUploadAction);
}
if (allActions.webdavActions) {
createNew.push(...allActions.webdavActions);
}
share.push(allActions.shareLocationAction);
const groups = {
...(apps.isSuccess &&
apps.data.length > 0 && {
Apps: apps.data,
}),
...messageBox.actions.get(),
};
const messageBoxActions = Object.entries(groups).reduce<Array<string | ToolbarAction>>((acc, [name, group]) => {
const items = group
.filter((item) => !hiddenActions.includes(item.id))
.map(
(item): ToolbarAction => ({
id: item.id,
icon: item.icon,
label: t(item.label),
onClick: item.action,
}),
);
if (items.length === 0) {
return acc;
}
return [...acc, (t.has(name) && t(name)) || name, ...items];
}, []);
const createNewFiltered = createNew.filter(isTruthy);
const shareFiltered = share.filter(isTruthy);
return {
featured: featured.filter(isTruthy),
menu: [
...(createNewFiltered.length > 0 ? ['Create_new', ...createNewFiltered] : []),
...(shareFiltered.length > 0 ? ['Share', ...shareFiltered] : []),
...messageBoxActions,
],
};
})();
return data;
};

@ -1,22 +1,14 @@
import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { VideoRecorder } from '../../../../../../../app/ui/client/lib/recorderjs/videoRecorder';
import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
import type { ToolbarAction } from './ToolbarAction';
type VideoMessageActionProps = {
collapsed?: boolean;
chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: VideoMessageActionProps) => {
export const useVideoMessageAction = (disabled: boolean): ToolbarAction => {
const t = useTranslation();
const isFileUploadEnabled = useSetting('FileUpload_Enabled') as boolean;
const isVideoRecorderEnabled = useSetting('Message_VideoRecorderEnabled') as boolean;
@ -41,7 +33,7 @@ const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: Vide
const getMediaActionTitle = useMediaActionTitle('video', isPermissionDenied, isFileUploadEnabled, isVideoRecorderEnabled, isAllowed);
const chat = useChat() ?? chatContext;
const chat = useChat();
const handleOpenVideoMessage = () => {
if (!chat?.composer?.recordingVideo.get()) {
@ -61,25 +53,12 @@ const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: Vide
handleDenyVideo(isPermissionDenied);
}, [handleDenyVideo, isPermissionDenied]);
if (collapsed) {
return (
<Option title={getMediaActionTitle} disabled={!isAllowed || disabled} onClick={handleOpenVideoMessage}>
<OptionIcon name='video' />
<OptionContent>{t('Video_message')}</OptionContent>
</Option>
);
}
return (
<MessageComposerAction
data-qa-id='video-message'
icon='video'
disabled={!isAllowed || disabled}
onClick={handleOpenVideoMessage}
title={getMediaActionTitle}
{...props}
/>
);
return {
id: 'video-message',
title: getMediaActionTitle,
disabled: !isAllowed || Boolean(disabled),
onClick: handleOpenVideoMessage,
icon: 'video',
label: t('Video_message'),
};
};
export default VideoMessageAction;

@ -1,18 +1,17 @@
import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings';
import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage';
import { useTranslation, useSetting, useSetModal } from '@rocket.chat/ui-contexts';
import React from 'react';
import { WebdavAccounts } from '../../../../../../../app/models/client';
import { useReactiveValue } from '../../../../../../hooks/useReactiveValue';
import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI';
import { useChat } from '../../../../contexts/ChatContext';
import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal';
import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal';
import type { ToolbarAction } from './ToolbarAction';
const getWebdavAccounts = (): IWebdavAccountIntegration[] => WebdavAccounts.find().fetch();
const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => {
export const useWebdavActions = (): Array<ToolbarAction> => {
const t = useTranslation();
const setModal = useSetModal();
const webDavAccounts = useReactiveValue(getWebdavAccounts);
@ -21,7 +20,7 @@ const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => {
const handleCreateWebDav = () => setModal(<AddWebdavAccountModal onClose={() => setModal(null)} onConfirm={() => setModal(null)} />);
const chat = useChat() ?? chatContext;
const chat = useChat();
const handleUpload = async (file: File, description?: string) =>
chat?.uploads.send(file, {
@ -31,26 +30,23 @@ const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => {
const handleOpenWebdav = (account: IWebdavAccountIntegration) =>
setModal(<WebdavFilePickerModal account={account} onUpload={handleUpload} onClose={() => setModal(null)} />);
return (
<>
<Option
{...(!webDavEnabled && { title: t('WebDAV_Integration_Not_Allowed') })}
disabled={!webDavEnabled}
onClick={handleCreateWebDav}
>
<OptionIcon name='cloud-plus' />
<OptionContent>{t('Add_Server')}</OptionContent>
</Option>
{webDavEnabled &&
webDavAccounts.length > 0 &&
webDavAccounts.map((account) => (
<Option key={account._id} onClick={() => handleOpenWebdav(account)}>
<OptionIcon name='cloud-plus' />
<OptionContent>{account.name}</OptionContent>
</Option>
))}
</>
);
return [
{
id: 'webdav-add',
title: !webDavEnabled ? t('WebDAV_Integration_Not_Allowed') : undefined,
disabled: !webDavEnabled,
onClick: handleCreateWebDav,
icon: 'cloud-plus',
label: t('Add_Server'),
},
...(webDavEnabled && webDavAccounts.length > 0
? webDavAccounts.map((account) => ({
id: account._id,
disabled: false,
onClick: () => handleOpenWebdav(account),
icon: 'cloud-plus' as const,
label: account.name,
}))
: []),
];
};
export default WebdavAction;

@ -1,5 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import { useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
@ -51,6 +52,7 @@ export const useUserInfoActions = (
const call = useCallAction(user);
const reportUserOption = useReportUser(user);
const isLayoutEmbedded = useEmbeddedLayout();
const { userToolbox: hiddenActions } = useLayoutHiddenActions();
const userinfoActions = useMemo(
() => ({
@ -83,7 +85,7 @@ export const useUserInfoActions = (
);
const actionSpread = useMemo(() => {
const entries = Object.entries(userinfoActions);
const entries = Object.entries(userinfoActions).filter(([key]) => !hiddenActions.includes(key));
const options = entries.slice(0, size);
const slicedOptions = entries.slice(size, entries.length);
@ -105,7 +107,7 @@ export const useUserInfoActions = (
}, [] as UserMenuAction);
return { actions: options, menuActions };
}, [size, userinfoActions]);
}, [size, userinfoActions, hiddenActions]);
return actionSpread;
};

@ -1,6 +1,6 @@
import type { RoomType, IRoom } from '@rocket.chat/core-typings';
import { useMutableCallback, useStableArray } from '@rocket.chat/fuselage-hooks';
import { useUserId, useSetting, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts';
import { useUserId, useSetting, useRouter, useRouteParameter, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import React, { useMemo } from 'react';
@ -87,10 +87,13 @@ const RoomToolboxProvider = ({ children }: RoomToolboxProviderProps) => {
const allowAnonymousRead = useSetting<boolean>('Accounts_AllowAnonymousRead', false);
const uid = useUserId();
const { roomToolbox: hiddenActions } = useLayoutHiddenActions();
const actions = useStableArray(
[...coreRoomActions, ...appsRoomActions]
.filter((action) => uid || (allowAnonymousRead && 'anonymous' in action && action.anonymous))
.filter((action) => !action.groups || action.groups.includes(getGroup(room)))
.filter((action) => !hiddenActions.includes(action.id))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
);

@ -149,7 +149,7 @@ export class HomeContent {
}
get btnRecordAudio(): Locator {
return this.page.locator('[data-qa-id="audio-record"]');
return this.page.locator('[data-qa-id="audio-message"]');
}
get btnMenuMoreActions() {

@ -20,6 +20,12 @@ export type LayoutContextValue = {
size: SizeLayout;
contextualBarExpanded: boolean;
contextualBarPosition: 'absolute' | 'relative' | 'fixed';
hiddenActions: {
roomToolbox: Array<string>;
messageToolbox: Array<string>;
composerToolbox: Array<string>;
userToolbox: Array<string>;
};
};
export const LayoutContext = createContext<LayoutContextValue>({
@ -40,4 +46,10 @@ export const LayoutContext = createContext<LayoutContextValue>({
},
contextualBarPosition: 'relative',
contextualBarExpanded: false,
hiddenActions: {
roomToolbox: [],
messageToolbox: [],
composerToolbox: [],
userToolbox: [],
},
});

@ -0,0 +1,6 @@
import { useContext } from 'react';
import type { LayoutContextValue } from '../LayoutContext';
import { LayoutContext } from '../LayoutContext';
export const useLayoutHiddenActions = (): LayoutContextValue['hiddenActions'] => useContext(LayoutContext).hiddenActions;

@ -38,6 +38,7 @@ export { useLayout } from './hooks/useLayout';
export { useLayoutContextualBarExpanded } from './hooks/useLayoutContextualBarExpanded';
export { useLayoutContextualBarPosition } from './hooks/useLayoutContextualBarPosition';
export { useLayoutSizes } from './hooks/useLayoutSizes';
export { useLayoutHiddenActions } from './hooks/useLayoutHiddenActions';
export { useLoadLanguage } from './hooks/useLoadLanguage';
export { useLoginWithPassword } from './hooks/useLoginWithPassword';
export { useLoginServices } from './hooks/useLoginServices';

Loading…
Cancel
Save