refactor: Message actions (1st iteration) (#34133)

pull/34171/head
Tasso Evangelista 1 year ago committed by GitHub
parent f11efb4011
commit 3d41ae2455
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 89
      apps/meteor/app/autotranslate/client/lib/actionButton.ts
  2. 2
      apps/meteor/app/autotranslate/client/lib/autotranslate.ts
  3. 3
      apps/meteor/app/ui-utils/client/index.ts
  4. 96
      apps/meteor/app/ui-utils/client/lib/MessageAction.ts
  5. 256
      apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts
  6. 2
      apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx
  7. 1
      apps/meteor/client/components/message/hooks/usePinMessageMutation.ts
  8. 1
      apps/meteor/client/components/message/hooks/useStarMessageMutation.ts
  9. 1
      apps/meteor/client/components/message/hooks/useUnpinMessageMutation.ts
  10. 1
      apps/meteor/client/components/message/hooks/useUnstarMessageMutation.ts
  11. 94
      apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx
  12. 211
      apps/meteor/client/components/message/toolbar/MessageToolbar.tsx
  13. 157
      apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx
  14. 35
      apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx
  15. 42
      apps/meteor/client/components/message/toolbar/MessageToolbarStarsActionMenu.tsx
  16. 26
      apps/meteor/client/components/message/toolbar/items/DefaultItems.tsx
  17. 16
      apps/meteor/client/components/message/toolbar/items/DirectItems.tsx
  18. 24
      apps/meteor/client/components/message/toolbar/items/FederatedItems.tsx
  19. 20
      apps/meteor/client/components/message/toolbar/items/MentionsItems.tsx
  20. 28
      apps/meteor/client/components/message/toolbar/items/MobileItems.tsx
  21. 20
      apps/meteor/client/components/message/toolbar/items/PinnedItems.tsx
  22. 20
      apps/meteor/client/components/message/toolbar/items/SearchItems.tsx
  23. 20
      apps/meteor/client/components/message/toolbar/items/StarredItems.tsx
  24. 26
      apps/meteor/client/components/message/toolbar/items/ThreadsItems.tsx
  25. 22
      apps/meteor/client/components/message/toolbar/items/VideoconfItems.tsx
  26. 22
      apps/meteor/client/components/message/toolbar/items/VideoconfThreadsItems.tsx
  27. 43
      apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx
  28. 29
      apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx
  29. 43
      apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx
  30. 62
      apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx
  31. 48
      apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx
  32. 39
      apps/meteor/client/components/message/toolbar/useCopyAction.ts
  33. 54
      apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts
  34. 59
      apps/meteor/client/components/message/toolbar/useEditMessageAction.ts
  35. 74
      apps/meteor/client/components/message/toolbar/useFollowMessageAction.ts
  36. 33
      apps/meteor/client/components/message/toolbar/useJumpToMessageContextAction.tsx
  37. 61
      apps/meteor/client/components/message/toolbar/useMarkAsUnreadMessageAction.ts
  38. 78
      apps/meteor/client/components/message/toolbar/useMessageActionAppsActionButtons.ts
  39. 116
      apps/meteor/client/components/message/toolbar/useNewDiscussionMessageAction.tsx
  40. 58
      apps/meteor/client/components/message/toolbar/usePermalinkAction.ts
  41. 50
      apps/meteor/client/components/message/toolbar/usePinMessageAction.tsx
  42. 39
      apps/meteor/client/components/message/toolbar/useReactionMessageAction.ts
  43. 37
      apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx
  44. 63
      apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts
  45. 46
      apps/meteor/client/components/message/toolbar/useReplyInThreadMessageAction.ts
  46. 53
      apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx
  47. 34
      apps/meteor/client/components/message/toolbar/useShowMessageReactionsAction.tsx
  48. 52
      apps/meteor/client/components/message/toolbar/useStarMessageAction.ts
  49. 59
      apps/meteor/client/components/message/toolbar/useTranslateAction.ts
  50. 74
      apps/meteor/client/components/message/toolbar/useUnFollowMessageAction.ts
  51. 41
      apps/meteor/client/components/message/toolbar/useUnpinMessageAction.tsx
  52. 52
      apps/meteor/client/components/message/toolbar/useUnstarMessageAction.ts
  53. 59
      apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts
  54. 60
      apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx
  55. 2
      apps/meteor/client/components/message/variants/RoomMessage.tsx
  56. 174
      apps/meteor/client/hooks/useAppActionButtons.ts
  57. 19
      apps/meteor/client/hooks/useFilterActions.ts
  58. 62
      apps/meteor/client/hooks/useMessageboxAppsActionButtons.ts
  59. 56
      apps/meteor/client/hooks/useUserDropdownAppsActionButtons.ts
  60. 5
      apps/meteor/client/lib/queryKeys.ts
  61. 2
      apps/meteor/client/providers/RouterProvider.tsx
  62. 2
      apps/meteor/client/sidebar/header/actions/hooks/useAppsItems.tsx
  63. 1
      apps/meteor/client/startup/index.ts
  64. 33
      apps/meteor/client/startup/readReceipt.ts
  65. 2
      apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx

@ -1,96 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { AutoTranslate } from './autotranslate';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import {
hasTranslationLanguageInAttachments,
hasTranslationLanguageInMessage,
} from '../../../../client/views/room/MessageList/lib/autoTranslate';
import { hasAtLeastOnePermission } from '../../../authorization/client';
import { Messages } from '../../../models/client';
import { settings } from '../../../settings/client';
import { MessageAction } from '../../../ui-utils/client/lib/MessageAction';
import { sdk } from '../../../utils/client/lib/SDKClient';
Meteor.startup(() => {
AutoTranslate.init();
Tracker.autorun(() => {
if (settings.get('AutoTranslate_Enabled') && hasAtLeastOnePermission(['auto-translate'])) {
MessageAction.addButton({
id: 'translate',
icon: 'language',
label: 'Translate',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
action(_, { message }) {
const language = AutoTranslate.getLanguage(message.rid);
if (!hasTranslationLanguageInMessage(message, language) && !hasTranslationLanguageInAttachments(message.attachments, language)) {
(AutoTranslate.messageIdsToWait as any)[message._id] = true;
Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } });
void sdk.call('autoTranslate.translateMessage', message, language);
}
const action = 'autoTranslateShowInverse' in message ? '$unset' : '$set';
Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } });
},
condition({ message, subscription, user, room }) {
if (!user) {
return false;
}
const language = subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid) || '';
const isLivechatRoom = roomCoordinator.isLivechatRoom(room?.t);
const isDifferentUser = message?.u && message.u._id !== user._id;
const autoTranslateEnabled = subscription?.autoTranslate || isLivechatRoom;
const hasLanguage =
hasTranslationLanguageInMessage(message, language) || hasTranslationLanguageInAttachments(message.attachments, language);
return Boolean(
(message as { autoTranslateShowInverse?: boolean }).autoTranslateShowInverse ||
(isDifferentUser && autoTranslateEnabled && !hasLanguage),
);
},
order: 90,
});
MessageAction.addButton({
id: 'view-original',
icon: 'language',
label: 'View_original',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
action(_, props) {
const { message } = props;
const language = AutoTranslate.getLanguage(message.rid);
if (!hasTranslationLanguageInMessage(message, language) && !hasTranslationLanguageInAttachments(message.attachments, language)) {
(AutoTranslate.messageIdsToWait as any)[message._id] = true;
Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } });
void sdk.call('autoTranslate.translateMessage', message, language);
}
const action = 'autoTranslateShowInverse' in message ? '$unset' : '$set';
Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } });
},
condition({ message, subscription, user, room }) {
const language = subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid) || '';
const isLivechatRoom = roomCoordinator.isLivechatRoom(room?.t);
if (!user) {
return false;
}
const isDifferentUser = message?.u && message.u._id !== user._id;
const autoTranslateEnabled = subscription?.autoTranslate || isLivechatRoom;
const hasLanguage =
hasTranslationLanguageInMessage(message, language) || hasTranslationLanguageInAttachments(message.attachments, language);
return Boolean(
!(message as { autoTranslateShowInverse?: boolean }).autoTranslateShowInverse &&
isDifferentUser &&
autoTranslateEnabled &&
hasLanguage,
);
},
order: 90,
});
} else {
MessageAction.removeButton('toggle-language');
}
});
});

@ -37,7 +37,7 @@ Meteor.startup(() => {
export const AutoTranslate = {
initialized: false,
providersMetadata: {} as { [providerNamer: string]: { name: string; displayName: string } },
messageIdsToWait: {} as { [messageId: string]: string },
messageIdsToWait: {} as { [messageId: string]: boolean },
supportedLanguages: [] as ISupportedLanguage[] | undefined,
findSubscriptionByRid: mem((rid) => Subscriptions.findOne({ rid })),

@ -1,6 +1,3 @@
import './lib/messageActionDefault';
export { MessageAction } from './lib/MessageAction';
export { messageBox } from './lib/messageBox';
export { LegacyRoomManager } from './lib/LegacyRoomManager';
export { upsertMessage, RoomHistoryManager } from './lib/RoomHistoryManager';

@ -1,14 +1,7 @@
import type { IMessage, IUser, ISubscription, IRoom, SettingValue, ITranslatedMessage } from '@rocket.chat/core-typings';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import mem from 'mem';
import type { ContextType } from 'react';
import type { AutoTranslateOptions } from '../../../../client/views/room/MessageList/hooks/useAutoTranslate';
import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext';
import type { RoomToolboxContextValue } from '../../../../client/views/room/contexts/RoomToolboxContext';
type MessageActionGroup = 'message' | 'menu';
type MessageActionGroup = 'menu';
export type MessageActionContext =
| 'message'
@ -25,92 +18,17 @@ export type MessageActionContext =
type MessageActionType = 'communication' | 'interaction' | 'duplication' | 'apps' | 'management';
export type MessageActionConditionProps = {
message: IMessage;
user: IUser | undefined;
room: IRoom;
subscription?: ISubscription;
context?: MessageActionContext;
settings: { [key: string]: SettingValue };
chat: ContextType<typeof ChatContext>;
};
export type MessageActionConfig = {
id: string;
icon: IconName;
variant?: 'danger' | 'success' | 'warning';
label: TranslationKey;
order?: number;
/* @deprecated */
color?: string;
role?: string;
group?: MessageActionGroup | MessageActionGroup[];
order: number;
/** @deprecated */
color?: 'alert';
group: MessageActionGroup;
context?: MessageActionContext[];
action: (
e: Pick<Event, 'preventDefault' | 'stopPropagation' | 'currentTarget'> | undefined,
{
message,
tabbar,
room,
chat,
autoTranslateOptions,
}: {
message: IMessage & Partial<ITranslatedMessage>;
tabbar: RoomToolboxContextValue;
room?: IRoom;
chat: ContextType<typeof ChatContext>;
autoTranslateOptions?: AutoTranslateOptions;
},
) => any;
condition?: (props: MessageActionConditionProps) => Promise<boolean> | boolean;
action: (e: Pick<Event, 'preventDefault' | 'stopPropagation' | 'currentTarget'> | undefined) => any;
type?: MessageActionType;
disabled?: (props: MessageActionConditionProps) => boolean;
disabled?: boolean;
};
class MessageAction {
public buttons: Record<MessageActionConfig['id'], MessageActionConfig> = {};
public addButton(config: MessageActionConfig): void {
if (!config?.id) {
return;
}
if (!config.group) {
config.group = 'menu';
}
if (config.condition) {
config.condition = mem(config.condition, { maxAge: 1000, cacheKey: JSON.stringify });
}
this.buttons[config.id] = config;
}
public removeButton(id: MessageActionConfig['id']): void {
delete this.buttons[id];
}
public async getAll(
props: MessageActionConditionProps,
context: MessageActionContext,
group: MessageActionGroup,
): Promise<MessageActionConfig[]> {
return (
await Promise.all(
Object.values(this.buttons)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.filter((button) => !button.group || (Array.isArray(button.group) ? button.group.includes(group) : button.group === group))
.filter((button) => !button.context || button.context.includes(context))
.map(async (button) => {
return [button, !button.condition || (await button.condition({ ...props, context }))] as const;
}),
)
)
.filter(([, condition]) => condition)
.map(([button]) => button);
}
}
const instance = new MessageAction();
export { instance as MessageAction };

@ -1,256 +0,0 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isE2EEMessage, isRoomFederated } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { MessageAction } from './MessageAction';
import { getPermaLink } from '../../../../client/lib/getPermaLink';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import { dispatchToastMessage } from '../../../../client/lib/toast';
import { router } from '../../../../client/providers/RouterProvider';
import ForwardMessageModal from '../../../../client/views/room/modals/ForwardMessageModal/ForwardMessageModal';
import ReactionListModal from '../../../../client/views/room/modals/ReactionListModal';
import ReportMessageModal from '../../../../client/views/room/modals/ReportMessageModal';
import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/client';
import { Rooms, Subscriptions } from '../../../models/client';
import { t } from '../../../utils/lib/i18n';
const getMainMessageText = (message: IMessage): IMessage => {
const newMessage = { ...message };
newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || '';
newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined;
return { ...newMessage };
};
Meteor.startup(async () => {
MessageAction.addButton({
id: 'reply-directly',
icon: 'reply-directly',
label: 'Reply_in_direct_message',
context: ['message', 'message-mobile', 'threads', 'federated'],
role: 'link',
type: 'communication',
action(_, { message }) {
roomCoordinator.openRouteLink(
'd',
{ name: message.u.username },
{
...router.getSearchParameters(),
reply: message._id,
},
);
},
condition({ subscription, room, message, user }) {
if (subscription == null) {
return false;
}
if (room.t === 'd' || room.t === 'l') {
return false;
}
// Check if we already have a DM started with the message user (not ourselves) or we can start one
if (!!user && user._id !== message.u._id && !hasPermission('create-d')) {
const dmRoom = Rooms.findOne({ _id: [user._id, message.u._id].sort().join('') });
if (!dmRoom || !Subscriptions.findOne({ 'rid': dmRoom._id, 'u._id': user._id })) {
return false;
}
}
return true;
},
order: 0,
group: 'menu',
disabled({ message }) {
return isE2EEMessage(message);
},
});
MessageAction.addButton({
id: 'forward-message',
icon: 'arrow-forward',
label: 'Forward_message',
context: ['message', 'message-mobile', 'threads'],
type: 'communication',
async action(_, { message }) {
const permalink = await getPermaLink(message._id);
imperativeModal.open({
component: ForwardMessageModal,
props: {
message,
permalink,
onClose: (): void => {
imperativeModal.close();
},
},
});
},
order: 0,
group: 'message',
disabled({ message }) {
return isE2EEMessage(message);
},
});
MessageAction.addButton({
id: 'quote-message',
icon: 'quote',
label: 'Quote',
context: ['message', 'message-mobile', 'threads', 'federated'],
async action(_, { message, chat, autoTranslateOptions }) {
if (message && autoTranslateOptions?.autoTranslateEnabled && autoTranslateOptions.showAutoTranslate(message)) {
message.msg =
message.translations && autoTranslateOptions.autoTranslateLanguage
? message.translations[autoTranslateOptions.autoTranslateLanguage]
: message.msg;
}
await chat?.composer?.quoteMessage(message);
},
condition({ subscription }) {
if (subscription == null) {
return false;
}
return true;
},
order: -2,
group: 'message',
});
MessageAction.addButton({
id: 'copy',
icon: 'copy',
label: 'Copy_text',
// classes: 'clipboard',
context: ['message', 'message-mobile', 'threads', 'federated'],
type: 'duplication',
async action(_, { message }) {
const msgText = getMainMessageText(message).msg;
await navigator.clipboard.writeText(msgText);
dispatchToastMessage({ type: 'success', message: t('Copied') });
},
condition({ subscription }) {
return !!subscription;
},
order: 6,
group: 'menu',
});
MessageAction.addButton({
id: 'edit-message',
icon: 'edit',
label: 'Edit',
context: ['message', 'message-mobile', 'threads', 'federated'],
type: 'management',
async action(_, { message, chat }) {
await chat?.messageEditing.editMessage(message);
},
condition({ message, subscription, settings, room, user }) {
if (subscription == null) {
return false;
}
if (isRoomFederated(room)) {
return message.u._id === user?._id;
}
const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid);
const isEditAllowed = settings.Message_AllowEditing;
const editOwn = message.u && message.u._id === user?._id;
if (!(canEditMessage || (isEditAllowed && editOwn))) {
return false;
}
const blockEditInMinutes = settings.Message_AllowEditing_BlockEditInMinutes as number;
const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid);
if (!bypassBlockTimeLimit && blockEditInMinutes) {
let msgTs;
if (message.ts != null) {
msgTs = moment(message.ts);
}
let currentTsDiff;
if (msgTs != null) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
return (!!currentTsDiff || currentTsDiff === 0) && currentTsDiff < blockEditInMinutes;
}
return true;
},
order: 8,
group: 'menu',
});
MessageAction.addButton({
id: 'delete-message',
icon: 'trash',
label: 'Delete',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
color: 'alert',
type: 'management',
async action(_, { message, chat }) {
await chat?.flows.requestMessageDeletion(message);
},
condition({ message, subscription, room, chat, user }) {
if (!subscription) {
return false;
}
if (isRoomFederated(room)) {
return message.u._id === user?._id;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom) {
return false;
}
return chat?.data.canDeleteMessage(message) ?? false;
},
order: 10,
group: 'menu',
});
MessageAction.addButton({
id: 'report-message',
icon: 'report',
label: 'Report',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
color: 'alert',
type: 'management',
action(_, { message }) {
imperativeModal.open({
component: ReportMessageModal,
props: {
message: getMainMessageText(message),
onClose: imperativeModal.close,
},
});
},
condition({ subscription, room, message, user }) {
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom || message.u._id === user?._id) {
return false;
}
return Boolean(subscription);
},
order: 9,
group: 'menu',
});
MessageAction.addButton({
id: 'reaction-list',
icon: 'emoji',
label: 'Reactions',
context: ['message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'],
type: 'interaction',
action(_, { message: { reactions = {} } }) {
imperativeModal.open({
component: ReactionListModal,
props: { reactions, onClose: imperativeModal.close },
});
},
condition({ message: { reactions } }) {
return !!reactions;
},
order: 9,
group: 'menu',
});
});

@ -3,7 +3,7 @@ import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, usePermission, useRouter } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useUserDropdownAppsActionButtons } from '../../../hooks/useAppActionButtons';
import { useUserDropdownAppsActionButtons } from '../../../hooks/useUserDropdownAppsActionButtons';
import { useAppRequestStats } from '../../../views/marketplace/hooks/useAppRequestStats';
export const useMarketPlaceMenu = () => {

@ -27,7 +27,6 @@ export const usePinMessageMutation = () => {
},
onSettled: (_data, _error, message) => {
queryClient.invalidateQueries(roomsQueryKeys.pinnedMessages(message.rid));
queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id));
},
});
};

@ -27,7 +27,6 @@ export const useStarMessageMutation = () => {
},
onSettled: (_data, _error, message) => {
queryClient.invalidateQueries(roomsQueryKeys.starredMessages(message.rid));
queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id));
},
});
};

@ -27,7 +27,6 @@ export const useUnpinMessageMutation = () => {
},
onSettled: (_data, _error, message) => {
queryClient.invalidateQueries(roomsQueryKeys.pinnedMessages(message.rid));
queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id));
},
});
};

@ -27,7 +27,6 @@ export const useUnstarMessageMutation = () => {
},
onSettled: (_data, _error, message) => {
queryClient.invalidateQueries(roomsQueryKeys.starredMessages(message.rid));
queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id));
},
});
};

@ -1,94 +0,0 @@
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import type { MouseEvent, ReactElement } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageActionConditionProps, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
type MessageActionConfigOption = Omit<MessageActionConfig, 'condition' | 'context' | 'order' | 'action'> & {
action: (e?: MouseEvent<HTMLElement>) => void;
};
type MessageActionSection = {
id: string;
title: string;
items: GenericMenuItemProps[];
};
type MessageActionMenuProps = {
onChangeMenuVisibility: (visible: boolean) => void;
options: MessageActionConfigOption[];
context: MessageActionConditionProps;
isMessageEncrypted: boolean;
};
const MessageActionMenu = ({ options, onChangeMenuVisibility, context, isMessageEncrypted }: MessageActionMenuProps): ReactElement => {
const { t } = useTranslation();
const id = useUniqueId();
const groupOptions = options
.map((option) => ({
variant: option.color === 'alert' ? 'danger' : '',
id: option.id,
icon: option.icon,
content: t(option.label),
onClick: option.action,
type: option.type,
...(option.disabled && { disabled: option?.disabled?.(context) }),
...(option.disabled &&
option?.disabled?.(context) && { tooltip: t('Action_not_available_encrypted_content', { action: t(option.label) }) }),
}))
.reduce(
(acc, option) => {
const group = option.type ? option.type : '';
const section = acc.find((section: { id: string }) => section.id === group);
if (section) {
section.items.push(option);
return acc;
}
const newSection = { id: group, title: group === 'apps' ? t('Apps') : '', items: [option] };
acc.push(newSection);
return acc;
},
[] as unknown as MessageActionSection[],
)
.map((section) => {
if (section.id !== 'apps') {
return section;
}
if (!isMessageEncrypted) {
return section;
}
return {
id: 'apps',
title: t('Apps'),
items: [
{
content: t('Unavailable'),
type: 'apps',
id,
disabled: true,
gap: false,
tooltip: t('Action_not_available_encrypted_content', { action: t('Apps') }),
},
],
};
});
return (
<GenericMenu
onOpenChange={onChangeMenuVisibility}
detached
title={t('More')}
data-qa-id='menu'
data-qa-type='message-action-menu'
sections={groupOptions}
placement='bottom-end'
/>
);
};
export default MessageActionMenu;

@ -1,39 +1,25 @@
import { useToolbar } from '@react-aria/toolbar';
import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings';
import { isThreadMessage, isRoomFederated, isVideoConfMessage, isE2EEMessage } from '@rocket.chat/core-typings';
import { MessageToolbar as FuselageMessageToolbar, MessageToolbarItem } from '@rocket.chat/fuselage';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useUser, useSettings, useTranslation, useMethod, useLayoutHiddenActions, useSetting } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo, useMemo, useRef } from 'react';
import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings';
import { MessageToolbar as FuselageMessageToolbar } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ElementType, ReactElement } from 'react';
import React, { memo, useRef } from 'react';
import MessageActionMenu from './MessageActionMenu';
import MessageToolbarActionMenu from './MessageToolbarActionMenu';
import MessageToolbarStarsActionMenu from './MessageToolbarStarsActionMenu';
import { useFollowMessageAction } from './useFollowMessageAction';
import { useJumpToMessageContextAction } from './useJumpToMessageContextAction';
import { useMarkAsUnreadMessageAction } from './useMarkAsUnreadMessageAction';
import { useNewDiscussionMessageAction } from './useNewDiscussionMessageAction';
import { usePermalinkAction } from './usePermalinkAction';
import { usePinMessageAction } from './usePinMessageAction';
import { useReactionMessageAction } from './useReactionMessageAction';
import { useReplyInThreadMessageAction } from './useReplyInThreadMessageAction';
import { useStarMessageAction } from './useStarMessageAction';
import { useUnFollowMessageAction } from './useUnFollowMessageAction';
import { useUnpinMessageAction } from './useUnpinMessageAction';
import { useUnstarMessageAction } from './useUnstarMessageAction';
import { useWebDAVMessageAction } from './useWebDAVMessageAction';
import DefaultItems from './items/DefaultItems';
import DirectItems from './items/DirectItems';
import FederatedItems from './items/FederatedItems';
import MentionsItems from './items/MentionsItems';
import MobileItems from './items/MobileItems';
import PinnedItems from './items/PinnedItems';
import SearchItems from './items/SearchItems';
import StarredItems from './items/StarredItems';
import ThreadsItems from './items/ThreadsItems';
import VideoconfItems from './items/VideoconfItems';
import VideoconfThreadsItems from './items/VideoconfThreadsItems';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useEmojiPickerData } from '../../../contexts/EmojiPickerContext';
import { useMessageActionAppsActionButtons } from '../../../hooks/useAppActionButtons';
import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout';
import { roomsQueryKeys } from '../../../lib/queryKeys';
import EmojiElement from '../../../views/composer/EmojiPicker/EmojiElement';
import { useIsSelecting } from '../../../views/room/MessageList/contexts/SelectedMessagesContext';
import { useAutoTranslate } from '../../../views/room/MessageList/hooks/useAutoTranslate';
import { useChat } from '../../../views/room/contexts/ChatContext';
import { useRoomToolbox } from '../../../views/room/contexts/RoomToolboxContext';
const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActionContext): MessageActionContext => {
if (context) {
@ -55,6 +41,23 @@ const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActi
return 'message';
};
const itemsByContext: Record<
MessageActionContext,
ElementType<{ message: IMessage; room: IRoom; subscription: ISubscription | undefined }>
> = {
'message': DefaultItems,
'message-mobile': MobileItems,
'threads': ThreadsItems,
'videoconf': VideoconfItems,
'videoconf-threads': VideoconfThreadsItems,
'pinned': PinnedItems,
'direct': DirectItems,
'starred': StarredItems,
'mentions': MentionsItems,
'federated': FederatedItems,
'search': SearchItems,
};
type MessageToolbarProps = {
message: IMessage & Partial<ITranslatedMessage>;
messageContext?: MessageActionContext;
@ -72,151 +75,25 @@ const MessageToolbar = ({
...props
}: MessageToolbarProps): ReactElement | null => {
const t = useTranslation();
const user = useUser() ?? undefined;
const settings = useSettings();
const isLayoutEmbedded = useEmbeddedLayout();
const toolbarRef = useRef(null);
const { toolbarProps } = useToolbar(props, toolbarRef);
const quickReactionsEnabled = useFeaturePreview('quickReactions');
const setReaction = useMethod('setReaction');
const context = getMessageContext(message, room, messageContext);
const mapSettings = useMemo(() => Object.fromEntries(settings.map((setting) => [setting._id, setting.value])), [settings]);
const chat = useChat();
const { quickReactions, addRecentEmoji } = useEmojiPickerData();
const actionButtonApps = useMessageActionAppsActionButtons(context);
const starsAction = useMessageActionAppsActionButtons(context, 'ai');
const { messageToolbox: hiddenActions } = useLayoutHiddenActions();
const allowStarring = useSetting('Message_AllowStarring');
// TODO: move this to another place
useWebDAVMessageAction();
useNewDiscussionMessageAction();
useUnpinMessageAction(message, { room, subscription });
usePinMessageAction(message, { room, subscription });
useStarMessageAction(message, { room, user });
useUnstarMessageAction(message, { room, user });
usePermalinkAction(message, { subscription, id: 'permalink-star', context: ['starred'], order: 10 });
usePermalinkAction(message, { subscription, id: 'permalink-pinned', context: ['pinned'], order: 5 });
usePermalinkAction(message, {
subscription,
id: 'permalink',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
type: 'duplication',
order: 5,
});
useFollowMessageAction(message, { room, user, context });
useUnFollowMessageAction(message, { room, user, context });
useReplyInThreadMessageAction(message, { room, subscription });
useJumpToMessageContextAction(message, {
id: 'jump-to-message',
order: 100,
context: ['mentions', 'threads', 'videoconf-threads', 'message-mobile', 'search'],
});
useJumpToMessageContextAction(message, {
id: 'jump-to-pin-message',
order: 100,
hidden: !subscription,
context: ['pinned', 'direct'],
});
useJumpToMessageContextAction(message, {
id: 'jump-to-star-message',
hidden: !allowStarring || !subscription,
order: 100,
context: ['starred'],
});
useReactionMessageAction(message, { user, room, subscription });
useMarkAsUnreadMessageAction(message, { user, room, subscription });
const actionsQueryResult = useQuery({
queryKey: roomsQueryKeys.messageActionsWithParameters(room._id, message),
queryFn: 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.filter((action) => !hiddenActions.includes(action.id)),
menu: menuItems.filter((action) => !(isLayoutEmbedded && action.id === 'reply-directly') && !hiddenActions.includes(action.id)),
};
},
keepPreviousData: true,
});
const toolbox = useRoomToolbox();
const selecting = useIsSelecting();
const autoTranslateOptions = useAutoTranslate(subscription);
if (selecting || (!actionsQueryResult.data?.message.length && !actionsQueryResult.data?.menu.length)) {
return null;
}
const isReactionAllowed = actionsQueryResult.data?.message.find(({ id }) => id === 'reaction-message');
const handleSetReaction = (emoji: string) => {
setReaction(`:${emoji}:`, message._id);
addRecentEmoji(emoji);
};
const MessageToolbarItems = itemsByContext[context];
return (
<FuselageMessageToolbar ref={toolbarRef} {...toolbarProps} aria-label={t('Message_actions')} {...props}>
{quickReactionsEnabled &&
isReactionAllowed &&
quickReactions.slice(0, 3).map(({ emoji, image }) => {
return <EmojiElement small key={emoji} title={emoji} emoji={emoji} image={image} onClick={() => handleSetReaction(emoji)} />;
})}
{actionsQueryResult.isSuccess &&
actionsQueryResult.data.message.map((action) => (
<MessageToolbarItem
onClick={(e): void => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions })}
key={action.id}
icon={action.icon}
title={
action?.disabled?.({ message, room, user, subscription, settings: mapSettings, chat, context })
? t('Action_not_available_encrypted_content', { action: t(action.label) })
: t(action.label)
}
data-qa-id={action.label}
data-qa-type='message-action-menu'
disabled={action?.disabled?.({ message, room, user, subscription, settings: mapSettings, chat, context })}
/>
))}
{starsAction.data && starsAction.data.length > 0 && (
<MessageToolbarStarsActionMenu
options={starsAction.data.map((action) => ({
...action,
action: (e) => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions }),
}))}
onChangeMenuVisibility={onChangeMenuVisibility}
data-qa-type='message-action-stars-menu-options'
context={{ message, room, user, subscription, settings: mapSettings, chat, context }}
isMessageEncrypted={isE2EEMessage(message)}
/>
)}
{actionsQueryResult.isSuccess && actionsQueryResult.data.menu.length > 0 && (
<MessageActionMenu
options={[...actionsQueryResult.data?.menu, ...(actionButtonApps.data ?? [])].filter(Boolean).map((action) => ({
...action,
action: (e) => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions }),
}))}
onChangeMenuVisibility={onChangeMenuVisibility}
data-qa-type='message-action-menu-options'
context={{ message, room, user, subscription, settings: mapSettings, chat, context }}
isMessageEncrypted={isE2EEMessage(message)}
/>
)}
<MessageToolbarItems message={message} room={room} subscription={subscription} />
<MessageToolbarStarsActionMenu message={message} context={context} onChangeMenuVisibility={onChangeMenuVisibility} />
<MessageToolbarActionMenu
message={message}
context={context}
room={room}
subscription={subscription}
onChangeMenuVisibility={onChangeMenuVisibility}
/>
</FuselageMessageToolbar>
);
};

@ -0,0 +1,157 @@
import { isE2EEMessage, type IMessage, type IRoom, type ISubscription } from '@rocket.chat/core-typings';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCopyAction } from './useCopyAction';
import { useDeleteMessageAction } from './useDeleteMessageAction';
import { useEditMessageAction } from './useEditMessageAction';
import { useFollowMessageAction } from './useFollowMessageAction';
import { useMarkAsUnreadMessageAction } from './useMarkAsUnreadMessageAction';
import { useMessageActionAppsActionButtons } from './useMessageActionAppsActionButtons';
import { useNewDiscussionMessageAction } from './useNewDiscussionMessageAction';
import { usePermalinkAction } from './usePermalinkAction';
import { usePinMessageAction } from './usePinMessageAction';
import { useReadReceiptsDetailsAction } from './useReadReceiptsDetailsAction';
import { useReplyInDMAction } from './useReplyInDMAction';
import { useReportMessageAction } from './useReportMessageAction';
import { useShowMessageReactionsAction } from './useShowMessageReactionsAction';
import { useStarMessageAction } from './useStarMessageAction';
import { useTranslateAction } from './useTranslateAction';
import { useUnFollowMessageAction } from './useUnFollowMessageAction';
import { useUnpinMessageAction } from './useUnpinMessageAction';
import { useUnstarMessageAction } from './useUnstarMessageAction';
import { useViewOriginalTranslationAction } from './useViewOriginalTranslationAction';
import { useWebDAVMessageAction } from './useWebDAVMessageAction';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { isTruthy } from '../../../../lib/isTruthy';
type MessageActionSection = {
id: string;
title: string;
items: GenericMenuItemProps[];
};
type MessageToolbarActionMenuProps = {
message: IMessage;
context: MessageActionContext;
room: IRoom;
subscription: ISubscription | undefined;
onChangeMenuVisibility: (visible: boolean) => void;
};
const MessageToolbarActionMenu = ({ message, context, room, subscription, onChangeMenuVisibility }: MessageToolbarActionMenuProps) => {
// TODO: move this to another place
const menuItems = [
useWebDAVMessageAction(message, { subscription }),
useNewDiscussionMessageAction(message, { room, subscription }),
useUnpinMessageAction(message, { room, subscription }),
usePinMessageAction(message, { room, subscription }),
useStarMessageAction(message, { room }),
useUnstarMessageAction(message, { room }),
usePermalinkAction(message, { id: 'permalink-star', context: ['starred'], order: 10 }),
usePermalinkAction(message, { id: 'permalink-pinned', context: ['pinned'], order: 5 }),
usePermalinkAction(message, {
id: 'permalink',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
type: 'duplication',
order: 5,
}),
useFollowMessageAction(message, { room, context }),
useUnFollowMessageAction(message, { room, context }),
useMarkAsUnreadMessageAction(message, { room, subscription }),
useTranslateAction(message, { room, subscription }),
useViewOriginalTranslationAction(message, { room, subscription }),
useReplyInDMAction(message, { room, subscription }),
useCopyAction(message, { subscription }),
useEditMessageAction(message, { room, subscription }),
useDeleteMessageAction(message, { room, subscription }),
useReportMessageAction(message, { room, subscription }),
useShowMessageReactionsAction(message),
useReadReceiptsDetailsAction(message),
];
const hiddenActions = useLayoutHiddenActions().messageToolbox;
const data = menuItems
.filter(isTruthy)
.filter((button) => button.group === 'menu')
.filter((button) => !button.context || button.context.includes(context))
.filter((action) => !hiddenActions.includes(action.id))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const actionButtonApps = useMessageActionAppsActionButtons(message, context);
const id = useUniqueId();
const { t } = useTranslation();
if (data.length === 0) {
return null;
}
const isMessageEncrypted = isE2EEMessage(message);
const groupOptions = [...data, ...(actionButtonApps.data ?? [])]
.map((option) => ({
variant: option.color === 'alert' ? 'danger' : '',
id: option.id,
icon: option.icon,
content: t(option.label),
onClick: option.action,
type: option.type,
...(typeof option.disabled === 'boolean' && { disabled: option.disabled }),
...(typeof option.disabled === 'boolean' &&
option.disabled && { tooltip: t('Action_not_available_encrypted_content', { action: t(option.label) }) }),
}))
.reduce((acc, option) => {
const group = option.type ? option.type : '';
const section = acc.find((section: { id: string }) => section.id === group);
if (section) {
section.items.push(option);
return acc;
}
const newSection = { id: group, title: group === 'apps' ? t('Apps') : '', items: [option] };
acc.push(newSection);
return acc;
}, [] as MessageActionSection[])
.map((section) => {
if (section.id !== 'apps') {
return section;
}
if (!isMessageEncrypted) {
return section;
}
return {
id: 'apps',
title: t('Apps'),
items: [
{
content: t('Unavailable'),
type: 'apps',
id,
disabled: true,
gap: false,
tooltip: t('Action_not_available_encrypted_content', { action: t('Apps') }),
},
],
};
});
return (
<GenericMenu
onOpenChange={onChangeMenuVisibility}
detached
title={t('More')}
data-qa-id='menu'
data-qa-type='message-action-menu-options'
sections={groupOptions}
placement='bottom-end'
/>
);
};
export default MessageToolbarActionMenu;

@ -0,0 +1,35 @@
import { MessageToolbarItem as FuselageMessageToolbarItem } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { MouseEventHandler } from 'react';
import React from 'react';
type MessageToolbarItemProps = {
id: string;
icon: IconName;
title: string;
disabled?: boolean;
qa: string;
onClick: MouseEventHandler;
};
const MessageToolbarItem = ({ id, icon, title, disabled, qa, onClick }: MessageToolbarItemProps) => {
const hiddenActions = useLayoutHiddenActions().messageToolbox;
if (hiddenActions.includes(id)) {
return null;
}
return (
<FuselageMessageToolbarItem
icon={icon}
title={title}
disabled={disabled}
data-qa-id={qa}
data-qa-type='message-action-menu'
onClick={onClick}
/>
);
};
export default MessageToolbarItem;

@ -1,14 +1,11 @@
import { isE2EEMessage, type IMessage } from '@rocket.chat/core-typings';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import type { MouseEvent, ReactElement } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageActionConditionProps, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
type MessageActionConfigOption = Omit<MessageActionConfig, 'condition' | 'context' | 'order' | 'action'> & {
action: (e?: MouseEvent<HTMLElement>) => void;
};
import { useMessageActionAppsActionButtons } from './useMessageActionAppsActionButtons';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
type MessageActionSection = {
id: string;
@ -17,22 +14,23 @@ type MessageActionSection = {
};
type MessageActionMenuProps = {
message: IMessage;
context: MessageActionContext;
onChangeMenuVisibility: (visible: boolean) => void;
options: MessageActionConfigOption[];
context: MessageActionConditionProps;
isMessageEncrypted: boolean;
};
const MessageToolbarStarsActionMenu = ({
options,
onChangeMenuVisibility,
context,
isMessageEncrypted,
}: MessageActionMenuProps): ReactElement => {
const MessageToolbarStarsActionMenu = ({ message, context, onChangeMenuVisibility }: MessageActionMenuProps) => {
const starsAction = useMessageActionAppsActionButtons(message, context, 'ai');
const { t } = useTranslation();
const id = useUniqueId();
const groupOptions = options.reduce((acc, option) => {
if (!starsAction.data?.length) {
return null;
}
const isMessageEncrypted = isE2EEMessage(message);
const groupOptions = starsAction.data.reduce((acc, option) => {
const transformedOption = {
variant: option.color === 'alert' ? 'danger' : '',
id: option.id,
@ -40,9 +38,9 @@ const MessageToolbarStarsActionMenu = ({
content: t(option.label),
onClick: option.action,
type: option.type,
...(option.disabled && { disabled: option?.disabled?.(context) }),
...(option.disabled &&
option?.disabled?.(context) && { tooltip: t('Action_not_available_encrypted_content', { action: t(option.label) }) }),
...(typeof option.disabled === 'boolean' && { disabled: option.disabled }),
...(typeof option.disabled === 'boolean' &&
option.disabled && { tooltip: t('Action_not_available_encrypted_content', { action: t(option.label) }) }),
};
const group = option.type || '';
@ -74,14 +72,14 @@ const MessageToolbarStarsActionMenu = ({
return (
<GenericMenu
onOpenChange={onChangeMenuVisibility}
detached
icon='stars'
title={t('AI_Actions')}
data-qa-id='menu'
data-qa-type='message-action-menu'
sections={groupOptions}
placement='bottom-end'
data-qa-id='menu'
data-qa-type='message-action-stars-menu-options'
onOpenChange={onChangeMenuVisibility}
/>
);
};

@ -0,0 +1,26 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import ForwardMessageAction from './actions/ForwardMessageAction';
import QuoteMessageAction from './actions/QuoteMessageAction';
import ReactionMessageAction from './actions/ReactionMessageAction';
import ReplyInThreadMessageAction from './actions/ReplyInThreadMessageAction';
type DefaultItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const DefaultItems = ({ message, room, subscription }: DefaultItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<QuoteMessageAction message={message} subscription={subscription} />
<ReplyInThreadMessageAction message={message} room={room} subscription={subscription} />
<ForwardMessageAction message={message} />
</>
);
};
export default DefaultItems;

@ -0,0 +1,16 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
type DirectItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const DirectItems = ({ message, subscription }: DirectItemsProps) => {
return <>{!!subscription && <JumpToMessageAction id='jump-to-pin-message' message={message} />}</>;
};
export default DirectItems;

@ -0,0 +1,24 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import QuoteMessageAction from './actions/QuoteMessageAction';
import ReactionMessageAction from './actions/ReactionMessageAction';
import ReplyInThreadMessageAction from './actions/ReplyInThreadMessageAction';
type FederatedItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const FederatedItems = ({ message, room, subscription }: FederatedItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<QuoteMessageAction message={message} subscription={subscription} />
<ReplyInThreadMessageAction message={message} room={room} subscription={subscription} />
</>
);
};
export default FederatedItems;

@ -0,0 +1,20 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
type MentionsItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const MentionsItems = ({ message }: MentionsItemsProps) => {
return (
<>
<JumpToMessageAction id='jump-to-message' message={message} />
</>
);
};
export default MentionsItems;

@ -0,0 +1,28 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import ForwardMessageAction from './actions/ForwardMessageAction';
import JumpToMessageAction from './actions/JumpToMessageAction';
import QuoteMessageAction from './actions/QuoteMessageAction';
import ReactionMessageAction from './actions/ReactionMessageAction';
import ReplyInThreadMessageAction from './actions/ReplyInThreadMessageAction';
type MobileItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const MobileItems = ({ message, room, subscription }: MobileItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<QuoteMessageAction message={message} subscription={subscription} />
<ReplyInThreadMessageAction message={message} room={room} subscription={subscription} />
<ForwardMessageAction message={message} />
<JumpToMessageAction id='jump-to-message' message={message} />
</>
);
};
export default MobileItems;

@ -0,0 +1,20 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
type PinnedItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const PinnedItems = ({ message }: PinnedItemsProps) => {
return (
<>
<JumpToMessageAction id='jump-to-pin-message' message={message} />
</>
);
};
export default PinnedItems;

@ -0,0 +1,20 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
type SearchItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const SearchItems = ({ message }: SearchItemsProps) => {
return (
<>
<JumpToMessageAction id='jump-to-message' message={message} />
</>
);
};
export default SearchItems;

@ -0,0 +1,20 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
type StarredItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const StarredItems = ({ message }: StarredItemsProps) => {
return (
<>
<JumpToMessageAction id='jump-to-star-message' message={message} />
</>
);
};
export default StarredItems;

@ -0,0 +1,26 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import ForwardMessageAction from './actions/ForwardMessageAction';
import JumpToMessageAction from './actions/JumpToMessageAction';
import QuoteMessageAction from './actions/QuoteMessageAction';
import ReactionMessageAction from './actions/ReactionMessageAction';
type ThreadsItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const ThreadsItems = ({ message, room, subscription }: ThreadsItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<QuoteMessageAction message={message} subscription={subscription} />
<ForwardMessageAction message={message} />
<JumpToMessageAction id='jump-to-message' message={message} />
</>
);
};
export default ThreadsItems;

@ -0,0 +1,22 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import ReactionMessageAction from './actions/ReactionMessageAction';
import ReplyInThreadMessageAction from './actions/ReplyInThreadMessageAction';
type VideoconfItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const VideoconfItems = ({ message, room, subscription }: VideoconfItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<ReplyInThreadMessageAction message={message} room={room} subscription={subscription} />
</>
);
};
export default VideoconfItems;

@ -0,0 +1,22 @@
import type { IRoom, ISubscription, IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import JumpToMessageAction from './actions/JumpToMessageAction';
import ReactionMessageAction from './actions/ReactionMessageAction';
type VideoconfThreadsItemsProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const VideoconfThreadsItems = ({ message, room, subscription }: VideoconfThreadsItemsProps) => {
return (
<>
<ReactionMessageAction message={message} room={room} subscription={subscription} />
<JumpToMessageAction id='jump-to-message' message={message} />
</>
);
};
export default VideoconfThreadsItems;

@ -0,0 +1,43 @@
import { type IMessage, isE2EEMessage } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getPermaLink } from '../../../../../lib/getPermaLink';
import ForwardMessageModal from '../../../../../views/room/modals/ForwardMessageModal';
import MessageToolbarItem from '../../MessageToolbarItem';
type ForwardMessageActionProps = {
message: IMessage;
};
const ForwardMessageAction = ({ message }: ForwardMessageActionProps) => {
const setModal = useSetModal();
const { t } = useTranslation();
const encrypted = isE2EEMessage(message);
return (
<MessageToolbarItem
id='forward-message'
icon='arrow-forward'
title={encrypted ? t('Action_not_available_encrypted_content', { action: t('Forward_message') }) : t('Forward_message')}
qa='Forward_message'
disabled={encrypted}
onClick={async () => {
const permalink = await getPermaLink(message._id);
setModal(
<ForwardMessageModal
message={message}
permalink={permalink}
onClose={() => {
setModal(null);
}}
/>,
);
}}
/>
);
};
export default ForwardMessageAction;

@ -0,0 +1,29 @@
import type { IMessage } from '@rocket.chat/core-typings';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter';
import MessageToolbarItem from '../../MessageToolbarItem';
type JumpToMessageActionProps = {
id: 'jump-to-message' | 'jump-to-pin-message' | 'jump-to-star-message';
message: IMessage;
};
const JumpToMessageAction = ({ id, message }: JumpToMessageActionProps) => {
const { t } = useTranslation();
return (
<MessageToolbarItem
id={id}
icon='jump'
title={t('Jump_to_message')}
qa='Jump_to_message'
onClick={() => {
setMessageJumpQueryStringParameter(message._id);
}}
/>
);
};
export default JumpToMessageAction;

@ -0,0 +1,43 @@
import type { ITranslatedMessage, IMessage, ISubscription } from '@rocket.chat/core-typings';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAutoTranslate } from '../../../../../views/room/MessageList/hooks/useAutoTranslate';
import { useChat } from '../../../../../views/room/contexts/ChatContext';
import MessageToolbarItem from '../../MessageToolbarItem';
type QuoteMessageActionProps = {
message: IMessage & Partial<ITranslatedMessage>;
subscription: ISubscription | undefined;
};
const QuoteMessageAction = ({ message, subscription }: QuoteMessageActionProps) => {
const chat = useChat();
const autoTranslateOptions = useAutoTranslate(subscription);
const { t } = useTranslation();
if (!chat || !subscription) {
return null;
}
return (
<MessageToolbarItem
id='quote-message'
icon='quote'
title={t('Quote')}
qa='Quote'
onClick={() => {
if (message && autoTranslateOptions?.autoTranslateEnabled && autoTranslateOptions.showAutoTranslate(message)) {
message.msg =
message.translations && autoTranslateOptions.autoTranslateLanguage
? message.translations[autoTranslateOptions.autoTranslateLanguage]
: message.msg;
}
chat?.composer?.quoteMessage(message);
}}
/>
);
};
export default QuoteMessageAction;

@ -0,0 +1,62 @@
import { isOmnichannelRoom, type IMessage, type IRoom, type ISubscription } from '@rocket.chat/core-typings';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useUser, useMethod } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useEmojiPickerData } from '../../../../../contexts/EmojiPickerContext';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import EmojiElement from '../../../../../views/composer/EmojiPicker/EmojiElement';
import { useChat } from '../../../../../views/room/contexts/ChatContext';
import MessageToolbarItem from '../../MessageToolbarItem';
type ReactionMessageActionProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageActionProps) => {
const chat = useChat();
const user = useUser();
const setReaction = useMethod('setReaction');
const quickReactionsEnabled = useFeaturePreview('quickReactions');
const { quickReactions, addRecentEmoji } = useEmojiPickerData();
const { t } = useTranslation();
if (!chat || !room || isOmnichannelRoom(room) || !subscription || message.private || !user) {
return null;
}
if (roomCoordinator.readOnly(room._id, user) && !room.reactWhenReadOnly) {
return null;
}
const toggleReaction = (emoji: string) => {
setReaction(`:${emoji}:`, message._id);
addRecentEmoji(emoji);
};
return (
<>
{quickReactionsEnabled &&
quickReactions.slice(0, 3).map(({ emoji, image }) => {
return <EmojiElement key={emoji} small title={emoji} emoji={emoji} image={image} onClick={() => toggleReaction(emoji)} />;
})}
<MessageToolbarItem
id='reaction-message'
icon='add-reaction'
title={t('Add_Reaction')}
qa='Add_Reaction'
onClick={(event) => {
event.stopPropagation();
chat.emojiPicker.open(event.currentTarget, (emoji) => {
toggleReaction(emoji);
});
}}
/>
</>
);
};
export default ReactionMessageAction;

@ -0,0 +1,48 @@
import { type IMessage, type ISubscription, type IRoom, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useRouter, useSetting } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useTranslation } from 'react-i18next';
import MessageToolbarItem from '../../MessageToolbarItem';
type ReplyInThreadMessageActionProps = {
message: IMessage;
room: IRoom;
subscription: ISubscription | undefined;
};
const ReplyInThreadMessageAction = ({ message, room, subscription }: ReplyInThreadMessageActionProps) => {
const router = useRouter();
const threadsEnabled = useSetting('Threads_enabled', true);
const { t } = useTranslation();
if (!threadsEnabled || isOmnichannelRoom(room) || !subscription) {
return null;
}
return (
<MessageToolbarItem
id='reply-in-thread'
icon='thread'
title={t('Reply_in_thread')}
qa='Reply_in_thread'
onClick={(event) => {
event.stopPropagation();
const routeName = router.getRouteName();
if (routeName) {
router.navigate({
name: routeName,
params: {
...router.getRouteParameters(),
tab: 'thread',
context: message.tmid || message._id,
},
});
}
}}
/>
);
};
export default ReplyInThreadMessageAction;

@ -0,0 +1,39 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
const getMainMessageText = (message: IMessage): IMessage => {
const newMessage = { ...message };
newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || '';
newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined;
return { ...newMessage };
};
export const useCopyAction = (
message: IMessage,
{ subscription }: { subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
if (!subscription) {
return null;
}
return {
id: 'copy',
icon: 'copy',
label: 'Copy_text',
context: ['message', 'message-mobile', 'threads', 'federated'],
type: 'duplication',
async action() {
const msgText = getMainMessageText(message).msg;
await navigator.clipboard.writeText(msgText);
dispatchToastMessage({ type: 'success', message: t('Copied') });
},
order: 6,
group: 'menu',
};
};

@ -0,0 +1,54 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { ISubscription, IRoom, IMessage } from '@rocket.chat/core-typings';
import { useUser } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import { useChat } from '../../../views/room/contexts/ChatContext';
export const useDeleteMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const chat = useChat();
const { data: condition = false } = useQuery({
queryKey: ['delete-message', message] as const,
queryFn: async () => {
if (!subscription) {
return false;
}
if (isRoomFederated(room)) {
return message.u._id === user?._id;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom) {
return false;
}
return chat?.data.canDeleteMessage(message) ?? false;
},
});
if (!condition) {
return null;
}
return {
id: 'delete-message',
icon: 'trash',
label: 'Delete',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
color: 'alert',
type: 'management',
async action() {
await chat?.flows.requestMessageDeletion(message);
},
order: 10,
group: 'menu',
};
};

@ -0,0 +1,59 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { IRoom, IMessage, ISubscription } from '@rocket.chat/core-typings';
import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts';
import moment from 'moment';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useChat } from '../../../views/room/contexts/ChatContext';
export const useEditMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const chat = useChat();
const isEditAllowed = useSetting('Message_AllowEditing', true);
const canEditMessage = usePermission('edit-message', message.rid);
const blockEditInMinutes = useSetting('Message_AllowEditing_BlockEditInMinutes', 0);
const canBypassBlockTimeLimit = usePermission('bypass-time-limit-edit-and-delete', message.rid);
if (!subscription) {
return null;
}
const condition = (() => {
if (isRoomFederated(room)) {
return message.u._id === user?._id;
}
const editOwn = message.u && message.u._id === user?._id;
if (!canEditMessage && (!isEditAllowed || !editOwn)) {
return false;
}
if (!canBypassBlockTimeLimit && blockEditInMinutes) {
const msgTs = message.ts ? moment(message.ts) : undefined;
const currentTsDiff = msgTs ? moment().diff(msgTs, 'minutes') : undefined;
return typeof currentTsDiff === 'number' && currentTsDiff < blockEditInMinutes;
}
return true;
})();
if (!condition) {
return null;
}
return {
id: 'edit-message',
icon: 'edit',
label: 'Edit',
context: ['message', 'message-mobile', 'threads', 'federated'],
type: 'management',
async action() {
await chat?.messageEditing.editMessage(message);
},
order: 8,
group: 'menu',
};
};

@ -1,12 +1,9 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useSetting, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
import { Messages } from '../../../../app/models/client';
import { MessageAction } from '../../../../app/ui-utils/client';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionContext, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { t } from '../../../../app/utils/lib/i18n';
import { useReactiveQuery } from '../../../hooks/useReactiveQuery';
import { roomsQueryKeys } from '../../../lib/queryKeys';
@ -14,14 +11,13 @@ import { useToggleFollowingThreadMutation } from '../../../views/room/contextual
export const useFollowMessageAction = (
message: IMessage,
{ room, user, context }: { room: IRoom; user: IUser | undefined; context: MessageActionContext },
) => {
{ room, context }: { room: IRoom; context: MessageActionContext },
): MessageActionConfig | null => {
const user = useUser();
const threadsEnabled = useSetting('Threads_enabled');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const { mutate: toggleFollowingThread } = useToggleFollowingThreadMutation({
onSuccess: () => {
dispatchToastMessage({
@ -36,40 +32,36 @@ export const useFollowMessageAction = (
Messages.findOne({ _id: tmid || _id }, { fields: { replies: 1 } }),
);
useEffect(() => {
if (!message || !threadsEnabled || isOmnichannelRoom(room)) {
return;
}
let { replies = [] } = message;
if (tmid || context) {
const parentMessage = messageQuery.data;
if (parentMessage) {
replies = parentMessage.replies || [];
}
}
if (!message || !threadsEnabled || isOmnichannelRoom(room)) {
return null;
}
if (!user?._id) {
return;
let { replies = [] } = message;
if (tmid || context) {
const parentMessage = messageQuery.data;
if (parentMessage) {
replies = parentMessage.replies || [];
}
}
if ((replies as string[]).includes(user._id)) {
return;
}
if (!user?._id) {
return null;
}
MessageAction.addButton({
id: 'follow-message',
icon: 'bell',
label: 'Follow_message',
type: 'interaction',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
action() {
toggleFollowingThread({ tmid: tmid || _id, follow: true, rid: room._id });
},
order: 1,
group: 'menu',
});
if (replies.includes(user._id)) {
return null;
}
return () => MessageAction.removeButton('follow-message');
}, [_id, context, message, messageQuery, messageQuery.data, queryClient, room, threadsEnabled, tmid, toggleFollowingThread, user]);
return {
id: 'follow-message',
icon: 'bell',
label: 'Follow_message',
type: 'interaction',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
action() {
toggleFollowingThread({ tmid: tmid || _id, follow: true, rid: room._id });
},
order: 1,
group: 'menu',
};
};

@ -1,33 +0,0 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useEffect } from 'react';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import { setMessageJumpQueryStringParameter } from '../../../lib/utils/setMessageJumpQueryStringParameter';
export const useJumpToMessageContextAction = (
message: IMessage,
{ id, order, hidden, context }: { id: string; order: number; hidden?: boolean; context: MessageActionContext[] },
) => {
useEffect(() => {
if (hidden) {
return;
}
MessageAction.addButton({
id,
icon: 'jump',
label: 'Jump_to_message',
context,
async action() {
setMessageJumpQueryStringParameter(message._id);
},
order,
group: 'message',
});
return () => {
MessageAction.removeButton(id);
};
}, [hidden, context, id, message._id, order]);
};

@ -1,47 +1,42 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ISubscription, IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import type { ISubscription, IMessage, IRoom } from '@rocket.chat/core-typings';
import { useRouter, useUser } from '@rocket.chat/ui-contexts';
import { MessageAction } from '../../../../app/ui-utils/client';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useMarkAsUnreadMutation } from '../hooks/useMarkAsUnreadMutation';
export const useMarkAsUnreadMessageAction = (
message: IMessage,
{ user, room, subscription }: { user: IUser | undefined; room: IRoom; subscription: ISubscription | undefined },
) => {
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const { mutateAsync: markAsUnread } = useMarkAsUnreadMutation();
const router = useRouter();
useEffect(() => {
if (isOmnichannelRoom(room) || !user) {
return;
}
if (isOmnichannelRoom(room) || !user) {
return null;
}
if (!subscription) {
return;
}
if (!subscription) {
return null;
}
if (message.u._id === user._id) {
return;
}
if (message.u._id === user._id) {
return null;
}
MessageAction.addButton({
id: 'mark-message-as-unread',
icon: 'flag',
label: 'Mark_unread',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
async action() {
router.navigate('/home');
await markAsUnread({ message, subscription });
},
order: 4,
group: 'menu',
});
return () => {
MessageAction.removeButton('mark-message-as-unread');
};
}, [markAsUnread, message, room, router, subscription, user]);
return {
id: 'mark-message-as-unread',
icon: 'flag',
label: 'Mark_unread',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
async action() {
router.navigate('/home');
await markAsUnread({ message, subscription });
},
order: 4,
group: 'menu',
};
};

@ -0,0 +1,78 @@
import { type IUIActionButton, MessageActionContext as AppsEngineMessageActionContext } from '@rocket.chat/apps-engine/definition/ui';
import type { IMessage } from '@rocket.chat/core-typings';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { UiKitTriggerTimeoutError } from '../../../../app/ui-message/client/UiKitTriggerTimeoutError';
import type { MessageActionContext, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { Utilities } from '../../../../ee/lib/misc/Utilities';
import { useAppActionButtons, getIdForActionButton } from '../../../hooks/useAppActionButtons';
import { useApplyButtonFilters } from '../../../hooks/useApplyButtonFilters';
import { useUiKitActionManager } from '../../../uikit/hooks/useUiKitActionManager';
const filterActionsByContext = (context: string | undefined, action: IUIActionButton) => {
if (!context) {
return true;
}
const messageActionContext = action.when?.messageActionContext || Object.values(AppsEngineMessageActionContext);
const isContextMatch = messageActionContext.includes(context as AppsEngineMessageActionContext);
return isContextMatch;
};
export const useMessageActionAppsActionButtons = (message: IMessage, context?: MessageActionContext, category?: string) => {
const result = useAppActionButtons('messageAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonFilters(category);
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const data = useMemo(
() =>
result.data
?.filter((action) => filterActionsByContext(context, action))
.filter((action) => applyButtonFilters(action))
.map((action) => {
const item: MessageActionConfig = {
icon: undefined as any,
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
order: 7,
type: 'apps',
variant: action.variant,
group: 'menu',
action: () => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
rid: message.rid,
tmid: message.tmid,
mid: message._id,
actionId: action.actionId,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
return item;
}),
[actionManager, applyButtonFilters, context, dispatchToastMessage, message._id, message.rid, message.tmid, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<MessageActionConfig[]>;
};

@ -1,68 +1,70 @@
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import React, { useEffect } from 'react';
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { usePermission, useSetModal, useSetting, useUser } from '@rocket.chat/ui-contexts';
import React from 'react';
import { hasPermission } from '../../../../app/authorization/client';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import CreateDiscussion from '../../CreateDiscussion';
export const useNewDiscussionMessageAction = () => {
export const useNewDiscussionMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const enabled = useSetting('Discussion_enabled', false);
const setModal = useSetModal();
useEffect(() => {
if (!enabled) {
return MessageAction.removeButton('start-discussion');
}
MessageAction.addButton({
id: 'start-discussion',
icon: 'discussion',
label: 'Discussion_start',
type: 'communication',
context: ['message', 'message-mobile', 'videoconf'],
async action(_, { message, room }) {
setModal(
<CreateDiscussion
defaultParentRoom={room?.prid || room?._id}
onClose={() => setModal(undefined)}
parentMessageId={message._id}
nameSuggestion={message?.msg?.substr(0, 140)}
/>,
);
},
condition({
message: {
u: { _id: uid },
drid,
dcount,
},
room,
subscription,
user,
}) {
if (drid || !Number.isNaN(Number(dcount))) {
return false;
}
if (!subscription) {
return false;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom) {
return false;
}
const canStartDiscussion = usePermission('start-discussion', room._id);
const canStartDiscussionOtherUser = usePermission('start-discussion-other-user', room._id);
if (!user) {
return false;
}
if (!enabled) {
return null;
}
return uid !== user._id ? hasPermission('start-discussion-other-user', room._id) : hasPermission('start-discussion', room._id);
},
order: 1,
group: 'menu',
});
return () => {
MessageAction.removeButton('start-discussion');
};
}, [enabled, setModal]);
const {
u: { _id: uid },
drid,
dcount,
} = message;
if (drid || !Number.isNaN(Number(dcount))) {
return null;
}
if (!subscription) {
return null;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom) {
return null;
}
if (!user) {
return null;
}
if (!(uid !== user._id ? canStartDiscussionOtherUser : canStartDiscussion)) {
return null;
}
return {
id: 'start-discussion',
icon: 'discussion',
label: 'Discussion_start',
type: 'communication',
context: ['message', 'message-mobile', 'videoconf'],
async action() {
setModal(
<CreateDiscussion
defaultParentRoom={room?.prid || room?._id}
onClose={() => setModal(undefined)}
parentMessageId={message._id}
nameSuggestion={message?.msg?.substr(0, 140)}
/>,
);
},
order: 1,
group: 'menu',
};
};

@ -1,52 +1,38 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import type { IMessage } from '@rocket.chat/core-typings';
import { isE2EEMessage } from '@rocket.chat/core-typings';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageActionConfig, MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import { getPermaLink } from '../../../lib/getPermaLink';
export const usePermalinkAction = (
message: IMessage,
{
subscription,
id,
context,
type,
order,
}: { subscription: ISubscription | undefined; context: MessageActionContext[]; order: number } & Pick<MessageActionConfig, 'id' | 'type'>,
) => {
{ id, context, type, order }: { context: MessageActionContext[]; order: number } & Pick<MessageActionConfig, 'id' | 'type'>,
): MessageActionConfig | null => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const encrypted = isE2EEMessage(message);
useEffect(() => {
MessageAction.addButton({
id,
icon: 'permalink',
label: 'Copy_link',
context,
type,
async action() {
try {
const permalink = await getPermaLink(message._id);
navigator.clipboard.writeText(permalink);
dispatchToastMessage({ type: 'success', message: t('Copied') });
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
},
order,
group: 'menu',
disabled: () => encrypted,
});
return () => {
MessageAction.removeButton(id);
};
}, [context, dispatchToastMessage, encrypted, id, message._id, order, subscription, t, type]);
return {
id,
icon: 'permalink',
label: 'Copy_link',
context,
type,
async action() {
try {
const permalink = await getPermaLink(message._id);
navigator.clipboard.writeText(permalink);
dispatchToastMessage({ type: 'success', message: t('Copied') });
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
},
order,
group: 'menu',
disabled: encrypted,
};
};

@ -1,47 +1,41 @@
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting, useSetModal, usePermission } from '@rocket.chat/ui-contexts';
import React, { useEffect } from 'react';
import React from 'react';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import PinMessageModal from '../../../views/room/modals/PinMessageModal';
import { usePinMessageMutation } from '../hooks/usePinMessageMutation';
export const usePinMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
) => {
): MessageActionConfig | null => {
const setModal = useSetModal();
const allowPinning = useSetting('Message_AllowPinning');
const hasPermission = usePermission('pin-message', room._id);
const { mutateAsync: pinMessage } = usePinMessageMutation();
useEffect(() => {
if (!allowPinning || isOmnichannelRoom(room) || !hasPermission || message.pinned || !subscription) {
return;
}
if (!allowPinning || isOmnichannelRoom(room) || !hasPermission || message.pinned || !subscription) {
return null;
}
const onConfirm = async () => {
pinMessage(message);
setModal(null);
};
const onConfirm = async () => {
pinMessage(message);
setModal(null);
};
MessageAction.addButton({
id: 'pin-message',
icon: 'pin',
label: 'Pin',
type: 'interaction',
context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'],
async action() {
setModal(<PinMessageModal message={message} onConfirm={onConfirm} onCancel={() => setModal(null)} />);
},
order: 2,
group: 'menu',
});
return () => {
MessageAction.removeButton('pin-message');
};
}, [allowPinning, hasPermission, message, pinMessage, room, setModal, subscription]);
return {
id: 'pin-message',
icon: 'pin',
label: 'Pin',
type: 'interaction',
context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'],
async action() {
setModal(<PinMessageModal message={message} onConfirm={onConfirm} onCancel={() => setModal(null)} />);
},
order: 2,
group: 'menu',
};
};

@ -1,39 +0,0 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IRoom, ISubscription, IUser, IMessage } from '@rocket.chat/core-typings';
import { useEffect } from 'react';
import { MessageAction } from '../../../../app/ui-utils/client';
import { sdk } from '../../../../app/utils/client/lib/SDKClient';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
export const useReactionMessageAction = (
message: IMessage,
{ user, room, subscription }: { user: IUser | undefined; room: IRoom; subscription: ISubscription | undefined },
) => {
useEffect(() => {
if (!room || isOmnichannelRoom(room) || !subscription || message.private || !user) {
return;
}
if (roomCoordinator.readOnly(room._id, user) && !room.reactWhenReadOnly) {
return;
}
MessageAction.addButton({
id: 'reaction-message',
icon: 'add-reaction',
label: 'Add_Reaction',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
action(event, { message, chat }) {
event?.stopPropagation();
chat?.emojiPicker.open(event?.currentTarget as Element, (emoji) => sdk.call('setReaction', `:${emoji}:`, message._id));
},
order: -3,
group: 'message',
});
return () => {
MessageAction.removeButton('reaction-message');
};
}, [message.private, room, subscription, user]);
};

@ -0,0 +1,37 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import ReadReceiptsModal from '../../../views/room/modals/ReadReceiptsModal';
export const useReadReceiptsDetailsAction = (message: IMessage): MessageActionConfig | null => {
const setModal = useSetModal();
const readReceiptsEnabled = useSetting('Message_Read_Receipt_Enabled', false);
const readReceiptsStoreUsers = useSetting('Message_Read_Receipt_Store_Users', false);
if (!readReceiptsEnabled || !readReceiptsStoreUsers) {
return null;
}
return {
id: 'receipt-detail',
icon: 'check-double',
label: 'Read_Receipts',
context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'],
type: 'duplication',
action() {
setModal(
<ReadReceiptsModal
messageId={message._id}
onClose={() => {
setModal(null);
}}
/>,
);
},
order: 10,
group: 'menu',
};
};

@ -0,0 +1,63 @@
import { type IMessage, type ISubscription, type IRoom, isE2EEMessage } from '@rocket.chat/core-typings';
import { usePermission, useRouter, useUser } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { Rooms, Subscriptions } from '../../../../app/models/client';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout';
import { useReactiveValue } from '../../../hooks/useReactiveValue';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
export const useReplyInDMAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const router = useRouter();
const encrypted = isE2EEMessage(message);
const canCreateDM = usePermission('create-d');
const isLayoutEmbedded = useEmbeddedLayout();
const condition = useReactiveValue(
useCallback(() => {
if (!subscription || room.t === 'd' || room.t === 'l' || isLayoutEmbedded) {
return false;
}
// Check if we already have a DM started with the message user (not ourselves) or we can start one
if (!!user && user._id !== message.u._id && !canCreateDM) {
const dmRoom = Rooms.findOne({ _id: [user._id, message.u._id].sort().join('') });
if (!dmRoom || !Subscriptions.findOne({ 'rid': dmRoom._id, 'u._id': user._id })) {
return false;
}
}
return true;
}, [canCreateDM, isLayoutEmbedded, message.u._id, room.t, subscription, user]),
);
if (!condition) {
return null;
}
return {
id: 'reply-directly',
icon: 'reply-directly',
label: 'Reply_in_direct_message',
context: ['message', 'message-mobile', 'threads', 'federated'],
type: 'communication',
action() {
roomCoordinator.openRouteLink(
'd',
{ name: message.u.username },
{
...router.getSearchParameters(),
reply: message._id,
},
);
},
order: 0,
group: 'menu',
disabled: encrypted,
};
};

@ -1,46 +0,0 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { useSetting, useRouter } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { MessageAction } from '../../../../app/ui-utils/client';
export const useReplyInThreadMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
) => {
const threadsEnabled = useSetting('Threads_enabled');
const route = useRouter();
useEffect(() => {
if (!threadsEnabled || isOmnichannelRoom(room) || !subscription) {
return;
}
MessageAction.addButton({
id: 'reply-in-thread',
icon: 'thread',
label: 'Reply_in_thread',
context: ['message', 'message-mobile', 'federated', 'videoconf'],
action(e) {
e?.stopPropagation();
const routeName = route.getRouteName();
if (routeName) {
route.navigate({
name: routeName,
params: {
...route.getRouteParameters(),
tab: 'thread',
context: message.tmid || message._id,
},
});
}
},
order: -1,
group: 'message',
});
return () => MessageAction.removeButton('unfollow-message');
}, [message._id, message.tmid, room, route, subscription, threadsEnabled]);
};

@ -0,0 +1,53 @@
import type { ISubscription, IRoom, IMessage } from '@rocket.chat/core-typings';
import { useSetModal, useUser } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import ReportMessageModal from '../../../views/room/modals/ReportMessageModal';
const getMainMessageText = (message: IMessage): IMessage => {
const newMessage = { ...message };
newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || '';
newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined;
return { ...newMessage };
};
export const useReportMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const setModal = useSetModal();
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (!subscription) {
return null;
}
if (isLivechatRoom || message.u._id === user?._id) {
return null;
}
return {
id: 'report-message',
icon: 'report',
label: 'Report',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
color: 'alert',
type: 'management',
action() {
setModal(
<ReportMessageModal
message={getMainMessageText(message)}
onClose={() => {
setModal(null);
}}
/>,
);
},
order: 9,
group: 'menu',
};
};

@ -0,0 +1,34 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import ReactionListModal from '../../../views/room/modals/ReactionListModal';
export const useShowMessageReactionsAction = (message: IMessage): MessageActionConfig | null => {
const setModal = useSetModal();
if (!message.reactions) {
return null;
}
return {
id: 'reaction-list',
icon: 'emoji',
label: 'Reactions',
context: ['message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'],
type: 'interaction',
action() {
setModal(
<ReactionListModal
reactions={message.reactions ?? {}}
onClose={() => {
setModal(null);
}}
/>,
);
},
order: 9,
group: 'menu',
};
};

@ -1,40 +1,34 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useStarMessageMutation } from '../hooks/useStarMessageMutation';
export const useStarMessageAction = (message: IMessage, { room, user }: { room: IRoom; user: IUser | undefined }) => {
export const useStarMessageAction = (message: IMessage, { room }: { room: IRoom }): MessageActionConfig | null => {
const user = useUser();
const allowStarring = useSetting('Message_AllowStarring', true);
const { mutateAsync: starMessage } = useStarMessageMutation();
useEffect(() => {
if (!allowStarring || isOmnichannelRoom(room)) {
return;
}
if (!allowStarring || isOmnichannelRoom(room)) {
return null;
}
if (Array.isArray(message.starred) && message.starred.some((star) => star._id === user?._id)) {
return;
}
if (Array.isArray(message.starred) && message.starred.some((star) => star._id === user?._id)) {
return null;
}
MessageAction.addButton({
id: 'star-message',
icon: 'star',
label: 'Star',
type: 'interaction',
context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
async action() {
await starMessage(message);
},
order: 3,
group: 'menu',
});
return () => {
MessageAction.removeButton('star-message');
};
}, [allowStarring, message, room, starMessage, user?._id]);
return {
id: 'star-message',
icon: 'star',
label: 'Star',
type: 'interaction',
context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
async action() {
await starMessage(message);
},
order: 3,
group: 'menu',
};
};

@ -0,0 +1,59 @@
import type { IMessage, ISubscription, IRoom } from '@rocket.chat/core-typings';
import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { AutoTranslate } from '../../../../app/autotranslate/client';
import { Messages } from '../../../../app/models/client';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../../../views/room/MessageList/lib/autoTranslate';
export const useTranslateAction = (
message: IMessage & { autoTranslateShowInverse?: boolean },
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const autoTranslateEnabled = useSetting('AutoTranslate_Enabled', false);
const canAutoTranslate = usePermission('auto-translate');
const translateMessage = useMethod('autoTranslate.translateMessage');
const language = useMemo(
() => subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid),
[message.rid, subscription?.autoTranslateLanguage],
);
const hasTranslations = useMemo(
() => hasTranslationLanguageInMessage(message, language) || hasTranslationLanguageInAttachments(message.attachments, language),
[message, language],
);
if (!autoTranslateEnabled || !canAutoTranslate || !user) {
return null;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room?.t);
const isDifferentUser = message?.u && message.u._id !== user._id;
const autoTranslationActive = subscription?.autoTranslate || isLivechatRoom;
if (!message.autoTranslateShowInverse && (!isDifferentUser || !autoTranslationActive || hasTranslations)) {
return null;
}
return {
id: 'translate',
icon: 'language',
label: 'Translate',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
group: 'menu',
action() {
if (!hasTranslations) {
AutoTranslate.messageIdsToWait[message._id] = true;
Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } });
void translateMessage(message, language);
}
const action = 'autoTranslateShowInverse' in message ? '$unset' : '$set';
Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } });
},
order: 90,
};
};

@ -1,12 +1,9 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useSetting, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
import { Messages } from '../../../../app/models/client';
import { MessageAction } from '../../../../app/ui-utils/client';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionContext, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { t } from '../../../../app/utils/lib/i18n';
import { useReactiveQuery } from '../../../hooks/useReactiveQuery';
import { roomsQueryKeys } from '../../../lib/queryKeys';
@ -14,14 +11,13 @@ import { useToggleFollowingThreadMutation } from '../../../views/room/contextual
export const useUnFollowMessageAction = (
message: IMessage,
{ room, user, context }: { room: IRoom; user: IUser | undefined; context: MessageActionContext },
) => {
{ room, context }: { room: IRoom; context: MessageActionContext },
): MessageActionConfig | null => {
const user = useUser();
const threadsEnabled = useSetting('Threads_enabled');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const { mutate: toggleFollowingThread } = useToggleFollowingThreadMutation({
onSuccess: () => {
dispatchToastMessage({
@ -37,41 +33,37 @@ export const useUnFollowMessageAction = (
() => Messages.findOne({ _id: tmid || _id }, { fields: { replies: 1 } }) ?? null,
);
useEffect(() => {
if (!message || !threadsEnabled || isOmnichannelRoom(room)) {
return;
}
let { replies } = message;
if (!message || !threadsEnabled || isOmnichannelRoom(room)) {
return null;
}
if (tmid || context) {
const parentMessage = messageQuery.data;
if (parentMessage) {
replies = parentMessage.replies || [];
}
}
let { replies } = message;
if (!user?._id) {
return;
if (tmid || context) {
const parentMessage = messageQuery.data;
if (parentMessage) {
replies = parentMessage.replies || [];
}
}
if (!replies?.includes(user._id)) {
return;
}
if (!user?._id) {
return null;
}
MessageAction.addButton({
id: 'unfollow-message',
icon: 'bell-off',
label: 'Unfollow_message',
type: 'interaction',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
action() {
toggleFollowingThread({ tmid: tmid || _id, follow: false, rid: room._id });
},
order: 1,
group: 'menu',
});
if (!replies?.includes(user._id)) {
return null;
}
return () => MessageAction.removeButton('unfollow-message');
}, [_id, context, message, messageQuery.data, queryClient, room, threadsEnabled, tmid, toggleFollowingThread, user]);
return {
id: 'unfollow-message',
icon: 'bell-off',
label: 'Unfollow_message',
type: 'interaction',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
action() {
toggleFollowingThread({ tmid: tmid || _id, follow: false, rid: room._id });
},
order: 1,
group: 'menu',
};
};

@ -1,40 +1,33 @@
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting, usePermission } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useUnpinMessageMutation } from '../hooks/useUnpinMessageMutation';
export const useUnpinMessageAction = (
message: IMessage,
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
) => {
): MessageActionConfig | null => {
const allowPinning = useSetting('Message_AllowPinning');
const hasPermission = usePermission('pin-message', room._id);
const { mutate: unpinMessage } = useUnpinMessageMutation();
useEffect(() => {
if (!allowPinning || isOmnichannelRoom(room) || !hasPermission || !message.pinned || !subscription) {
return;
}
if (!allowPinning || isOmnichannelRoom(room) || !hasPermission || !message.pinned || !subscription) {
return null;
}
MessageAction.addButton({
id: 'unpin-message',
icon: 'pin',
label: 'Unpin',
type: 'interaction',
context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'],
action() {
unpinMessage(message);
},
order: 2,
group: 'menu',
});
return () => {
MessageAction.removeButton('unpin-message');
};
}, [allowPinning, hasPermission, message, room, subscription, unpinMessage]);
return {
id: 'unpin-message',
icon: 'pin',
label: 'Unpin',
type: 'interaction',
context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'],
action() {
unpinMessage(message);
},
order: 2,
group: 'menu',
};
};

@ -1,40 +1,34 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { useUnstarMessageMutation } from '../hooks/useUnstarMessageMutation';
export const useUnstarMessageAction = (message: IMessage, { room, user }: { room: IRoom; user: IUser | undefined }) => {
export const useUnstarMessageAction = (message: IMessage, { room }: { room: IRoom }): MessageActionConfig | null => {
const user = useUser();
const allowStarring = useSetting('Message_AllowStarring');
const { mutateAsync: unstarMessage } = useUnstarMessageMutation();
useEffect(() => {
if (!allowStarring || isOmnichannelRoom(room)) {
return;
}
if (!allowStarring || isOmnichannelRoom(room)) {
return null;
}
if (!Array.isArray(message.starred) || message.starred.every((star) => star._id !== user?._id)) {
return;
}
if (!Array.isArray(message.starred) || message.starred.every((star) => star._id !== user?._id)) {
return null;
}
MessageAction.addButton({
id: 'unstar-message',
icon: 'star',
label: 'Unstar_Message',
type: 'interaction',
context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
async action() {
await unstarMessage(message);
},
order: 3,
group: 'menu',
});
return () => {
MessageAction.removeButton('unstar-message');
};
}, [allowStarring, message, room, unstarMessage, user?._id]);
return {
id: 'unstar-message',
icon: 'star',
label: 'Unstar_Message',
type: 'interaction',
context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
async action() {
await unstarMessage(message);
},
order: 3,
group: 'menu',
};
};

@ -0,0 +1,59 @@
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { AutoTranslate } from '../../../../app/autotranslate/client';
import { Messages } from '../../../../app/models/client';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../../../views/room/MessageList/lib/autoTranslate';
export const useViewOriginalTranslationAction = (
message: IMessage & { autoTranslateShowInverse?: boolean },
{ room, subscription }: { room: IRoom; subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const user = useUser();
const autoTranslateEnabled = useSetting('AutoTranslate_Enabled', false);
const canAutoTranslate = usePermission('auto-translate');
const translateMessage = useMethod('autoTranslate.translateMessage');
const language = useMemo(
() => subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid),
[message.rid, subscription?.autoTranslateLanguage],
);
const hasTranslations = useMemo(
() => hasTranslationLanguageInMessage(message, language) || hasTranslationLanguageInAttachments(message.attachments, language),
[message, language],
);
if (!autoTranslateEnabled || !canAutoTranslate || !user) {
return null;
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room?.t);
const isDifferentUser = message?.u && message.u._id !== user._id;
const autoTranslationActive = subscription?.autoTranslate || isLivechatRoom;
if (message.autoTranslateShowInverse || !isDifferentUser || !autoTranslationActive || !hasTranslations) {
return null;
}
return {
id: 'view-original',
icon: 'language',
label: 'View_original',
context: ['message', 'message-mobile', 'threads'],
type: 'interaction',
group: 'menu',
action() {
if (!hasTranslations) {
AutoTranslate.messageIdsToWait[message._id] = true;
Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } });
void translateMessage(message, language);
}
const action = 'autoTranslateShowInverse' in message ? '$unset' : '$set';
Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } });
},
order: 90,
};
};

@ -1,42 +1,44 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import React, { useEffect } from 'react';
import React from 'react';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction';
import { getURL } from '../../../../app/utils/client';
import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery';
import SaveToWebdavModal from '../../../views/room/webdav/SaveToWebdavModal';
export const useWebDAVMessageAction = () => {
export const useWebDAVMessageAction = (
message: IMessage,
{ subscription }: { subscription: ISubscription | undefined },
): MessageActionConfig | null => {
const enabled = useSetting('Webdav_Integration_Enabled', false);
const { data } = useWebDAVAccountIntegrationsQuery({ enabled });
const setModal = useSetModal();
useEffect(() => {
if (!enabled) {
return;
}
MessageAction.addButton({
id: 'webdav-upload',
icon: 'upload',
label: 'Save_To_Webdav',
condition: ({ message, subscription }) => {
return !!subscription && !!data?.length && !!message.file;
},
action(_, { message }) {
const [attachment] = message.attachments || [];
const url = getURL(attachment.title_link as string, { full: true });
setModal(<SaveToWebdavModal data={{ attachment, url }} onClose={() => setModal(undefined)} />);
},
order: 100,
group: 'menu',
});
return () => {
MessageAction.removeButton('webdav-upload');
};
}, [data?.length, enabled, setModal]);
if (!enabled || !subscription || !data?.length || !message.file) {
return null;
}
return {
id: 'webdav-upload',
icon: 'upload',
label: 'Save_To_Webdav',
action() {
const [attachment] = message.attachments || [];
const url = getURL(attachment.title_link as string, { full: true });
setModal(
<SaveToWebdavModal
data={{ attachment, url }}
onClose={() => {
setModal(null);
}}
/>,
);
},
order: 100,
group: 'menu',
};
};

@ -110,7 +110,7 @@ const RoomMessage = ({
<RoomMessageContent message={message} unread={unread} mention={mention} all={all} searchText={searchText} />
)}
</MessageContainer>
{!message.private && message?.e2e !== 'pending' && <MessageToolbarHolder message={message} context={context} />}
{!message.private && message?.e2e !== 'pending' && !selecting && <MessageToolbarHolder message={message} context={context} />}
</Message>
);
};

@ -1,21 +1,10 @@
import { type IUIActionButton, type UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useEndpoint, useStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
import { useApplyButtonFilters, useApplyButtonAuthFilter } from './useApplyButtonFilters';
import { useFilterActionsByContext } from './useFilterActions';
import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError';
import type { MessageActionConfig, MessageActionContext } from '../../app/ui-utils/client/lib/MessageAction';
import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox';
import { Utilities } from '../../ee/lib/misc/Utilities';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`;
export const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`;
export const useAppActionButtons = <TContext extends `${UIActionButtonContext}`>(context?: TContext) => {
const queryClient = useQueryClient();
@ -61,160 +50,3 @@ export const useAppActionButtons = <TContext extends `${UIActionButtonContext}`>
return result;
};
export const useMessageboxAppsActionButtons = () => {
const result = useAppActionButtons('messageBoxAction');
const actionManager = useUiKitActionManager();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const applyButtonFilters = useApplyButtonFilters();
const data = useMemo(
() =>
result.data
?.filter((action) => {
return applyButtonFilters(action);
})
.map((action) => {
const item: Omit<MessageBoxAction, 'icon'> = {
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
action: (params) => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
rid: params.rid,
tmid: params.tmid,
actionId: action.actionId,
payload: { context: action.context, message: params.chat.composer?.text ?? '' },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
return item;
}),
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<MessageBoxAction[]>;
};
export const useUserDropdownAppsActionButtons = () => {
const result = useAppActionButtons('userDropdownAction');
const actionManager = useUiKitActionManager();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const applyButtonFilters = useApplyButtonAuthFilter();
const data = useMemo(
() =>
result.data
?.filter((action) => {
return applyButtonFilters(action);
})
.map((action) => {
return {
id: `${action.appId}_${action.actionId}`,
// icon: action.icon as GenericMenuItemProps['icon'],
content: action.labelI18n,
onClick: () => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
actionId: action.actionId,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
}),
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<GenericMenuItemProps[]>;
};
export const useMessageActionAppsActionButtons = (context?: MessageActionContext, category?: string) => {
const result = useAppActionButtons('messageAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonFilters(category);
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const filterActionsByContext = useFilterActionsByContext(context);
const data = useMemo(
() =>
result.data
?.filter((action) => {
if (!filterActionsByContext(action)) {
return false;
}
return applyButtonFilters(action);
})
.map((action) => {
const item: MessageActionConfig = {
icon: undefined as any,
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
order: 7,
type: 'apps',
variant: action.variant,
action: (_, params) => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
rid: params.message.rid,
tmid: params.message.tmid,
mid: params.message._id,
actionId: action.actionId,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
return item;
}),
[actionManager, applyButtonFilters, dispatchToastMessage, filterActionsByContext, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<MessageActionConfig[]>;
};

@ -1,19 +0,0 @@
import { MessageActionContext } from '@rocket.chat/apps-engine/definition/ui';
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';
import { useCallback } from 'react';
export const useFilterActionsByContext = (context: string | undefined) => {
return useCallback(
(action: IUIActionButton) => {
if (!context) {
return true;
}
const messageActionContext = action.when?.messageActionContext || Object.values(MessageActionContext);
const isContextMatch = messageActionContext.includes(context as MessageActionContext);
return isContextMatch;
},
[context],
);
};

@ -0,0 +1,62 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppActionButtons, getIdForActionButton } from './useAppActionButtons';
import { useApplyButtonFilters } from './useApplyButtonFilters';
import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError';
import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox';
import { Utilities } from '../../ee/lib/misc/Utilities';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
export const useMessageboxAppsActionButtons = (): UseQueryResult<MessageBoxAction[]> => {
const result = useAppActionButtons('messageBoxAction');
const actionManager = useUiKitActionManager();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const applyButtonFilters = useApplyButtonFilters();
const data = useMemo(
() =>
result.data
?.filter((action) => {
return applyButtonFilters(action);
})
.map((action) => {
const item: Omit<MessageBoxAction, 'icon'> = {
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
action: (params) => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
rid: params.rid,
tmid: params.tmid,
actionId: action.actionId,
payload: { context: action.context, message: params.chat.composer?.text ?? '' },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
return item;
}),
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<MessageBoxAction[]>;
};

@ -0,0 +1,56 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppActionButtons } from './useAppActionButtons';
import { useApplyButtonAuthFilter } from './useApplyButtonFilters';
import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
export const useUserDropdownAppsActionButtons = () => {
const result = useAppActionButtons('userDropdownAction');
const actionManager = useUiKitActionManager();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const applyButtonFilters = useApplyButtonAuthFilter();
const data = useMemo(
() =>
result.data
?.filter((action) => applyButtonFilters(action))
.map((action) => {
return {
id: `${action.appId}_${action.actionId}`,
// icon: action.icon as GenericMenuItemProps['icon'],
content: action.labelI18n,
onClick: () => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
actionId: action.actionId,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
}),
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, t],
);
return {
...result,
data,
} as UseQueryResult<GenericMenuItemProps[]>;
};

@ -1,4 +1,4 @@
import type { IMessage, IRoom, Serialized } from '@rocket.chat/core-typings';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
export const roomsQueryKeys = {
all: ['rooms'] as const,
@ -7,9 +7,6 @@ export const roomsQueryKeys = {
pinnedMessages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'pinned-messages'] as const,
messages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'messages'] as const,
message: (rid: IRoom['_id'], mid: IMessage['_id']) => [...roomsQueryKeys.messages(rid), mid] as const,
messageActions: (rid: IRoom['_id'], mid: IMessage['_id']) => [...roomsQueryKeys.message(rid, mid), 'actions'] as const,
messageActionsWithParameters: (rid: IRoom['_id'], message: IMessage | Serialized<IMessage>) =>
[...roomsQueryKeys.messageActions(rid, message._id), message] as const,
threads: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'threads'] as const,
};

@ -46,7 +46,7 @@ const subscribeToRouteChange = (onRouteChange: () => void): (() => void) => {
};
};
const getLocationPathname = () => FlowRouter.current().path as LocationPathname;
const getLocationPathname = () => FlowRouter.current().path.replace(/\?.*/, '') as LocationPathname;
const getLocationSearch = () => location.search as LocationSearch;

@ -3,7 +3,7 @@ import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useRoute, usePermission } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useUserDropdownAppsActionButtons } from '../../../../hooks/useAppActionButtons';
import { useUserDropdownAppsActionButtons } from '../../../../hooks/useUserDropdownAppsActionButtons';
import { useAppRequestStats } from '../../../../views/marketplace/hooks/useAppRequestStats';
/**

@ -16,7 +16,6 @@ import './messageObserve';
import './messageTypes';
import './notifications';
import './otr';
import './readReceipt';
import './reloadRoomAfterLogin';
import './roles';
import './rootUrlChange';

@ -1,33 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { settings } from '../../app/settings/client';
import { MessageAction } from '../../app/ui-utils/client';
import { imperativeModal } from '../lib/imperativeModal';
import ReadReceiptsModal from '../views/room/modals/ReadReceiptsModal';
Meteor.startup(() => {
Tracker.autorun(() => {
const enabled = settings.get('Message_Read_Receipt_Enabled') && settings.get('Message_Read_Receipt_Store_Users');
if (!enabled) {
return MessageAction.removeButton('receipt-detail');
}
MessageAction.addButton({
id: 'receipt-detail',
icon: 'check-double',
label: 'Read_Receipts',
context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'],
type: 'duplication',
action(_, { message }) {
imperativeModal.open({
component: ReadReceiptsModal,
props: { messageId: message._id, onClose: imperativeModal.close },
});
},
order: 10,
group: 'menu',
});
});
});

@ -16,7 +16,7 @@ import { useVideoMessageAction } from './hooks/useVideoMessageAction';
import { useWebdavActions } from './hooks/useWebdavActions';
import { messageBox } from '../../../../../../app/ui-utils/client';
import { isTruthy } from '../../../../../../lib/isTruthy';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useAppActionButtons';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useMessageboxAppsActionButtons';
import { useChat } from '../../../contexts/ChatContext';
import { useRoom } from '../../../contexts/RoomContext';

Loading…
Cancel
Save