From c3417ddba15abc9474457f5af07388c863e6518b Mon Sep 17 00:00:00 2001 From: pereira0x Date: Wed, 31 Jul 2024 17:50:27 +0100 Subject: [PATCH] Share an email thread to workspace members chip and dropdown (#4199) (#5640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Feature: Email thread members visibility For this feature we implemented a chip and a dropdown menu that allows users to check which workspace members can see an email thread, as depicted on issue (#4199). ## Implementations - create a new database table (messageThreadMember) - relations between `messageThreadMembers` and the relevant existing tables (`MessageThread` and `WorkspaceMembers`) - added a new column to the `MessageThread table`: `everyone` - to indicate that all workspace members can see the email thread - create a new repository for the new table, including new queries - edit the queries so that the new fields could be fetched from the frontend - created a component `MultiChip`, that shows a group of user avatars, instead of just one - created a component, `ShareDropdownMenu`, that shows up once the `EmailThreadMembersChip` is clicked. On this menu you can see which workspace members can view the email thread. ## Screenshots Here are some screenshots of the frontend components that were created: Chip with everyone in the workspace being part of the message thread: ![image](https://github.com/twentyhq/twenty/assets/26422084/80d75cdc-656f-490d-9eb1-a07346aad75c) Chip with just one member of the workspace (the owner) being part of the message thread: ![image](https://github.com/twentyhq/twenty/assets/26422084/c26677c6-ab93-4149-8201-b110d7346a28) Chip with some members of the workspace being part of the message thread: ![image](https://github.com/twentyhq/twenty/assets/26422084/9eccf5f8-134c-4c62-9145-5d5aa2346071) How the chip looks in a message thread: ![image](https://github.com/twentyhq/twenty/assets/26422084/a9de981d-7288-4aed-8616-c1cb7de524e2) Dropdown that opens when you click on the chip: ![image](https://github.com/twentyhq/twenty/assets/26422084/a1bb9cd4-01bb-45c5-bf8b-b31c2f3d85e0) ## Testing and Mock data We also added mock data (TypeORM seeds), focusing on adding mock data related to message thread members. ## Conclusion As some of the changes that we needed to do, regarding the change of visibility of the message thread, were not covered by the existing documentation, we were told to open a PR and ask for feedback on this part of the implementation. Right now, our implementation is focused on displaying who is part of an email thread. Feel free to let us know which steps we should follow next :) --------- Co-authored-by: Simão Sanguinho Co-authored-by: Lucas Bordeau --- .../components/EmailThreadMembersChip.tsx | 16 +++ ...eThreadSubscriberDropdownAddSubscriber.tsx | 40 +++++++ ...ubscriberDropdownAddSubscriberMenuItem.tsx | 43 +++++++ .../MessageThreadSubscribersChip.tsx | 80 +++++++++++++ ...MessageThreadSubscribersDropdownButton.tsx | 110 ++++++++++++++++++ ...ThreadMessagesOperationSignatureFactory.ts | 20 +++- .../components/RightDrawerEmailThread.tsx | 38 +++--- .../hooks/useRightDrawerEmailThread.ts | 14 ++- .../emails/types/EmailThreadMessage.ts | 2 + .../activities/emails/types/MessageThread.ts | 6 + .../emails/types/MessageThreadSubscriber.ts | 7 ++ .../MessageThreadSubscribersTopBar.tsx | 40 +++++++ .../types/CoreObjectNameSingular.ts | 1 + .../SingleEntitySelectMenuItemsWithSearch.tsx | 3 +- .../ui/layout/dropdown/hooks/useDropdown.ts | 5 +- .../right-drawer/components/RightDrawer.tsx | 98 ++++++++-------- .../components/RightDrawerRouter.tsx | 40 +++---- .../components/RightDrawerTopBar.tsx | 23 +++- .../RightDrawerTopBarDropdownButton.tsx | 27 +++++ .../hooks/useListenRightDrawerClose.ts | 12 ++ .../right-drawer/states/messageThreadState.ts | 8 ++ .../rightDrawerHeaderDropdownButtonState.ts | 9 ++ .../types/ComponentByRightDrawerPage.ts | 5 + .../types/RightDrawerTopBarDropdownButtons.ts | 3 + .../utils/emitRightDrawerCloseEvent.ts | 5 + .../menu-item/components/MenuItem.tsx | 3 +- .../menu-item/components/MenuItemAvatar.tsx | 106 +++++++++++++++++ .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../data-seed-dev-workspace.command.ts | 23 ++++ .../typeorm-seeds/core/feature-flags.ts | 5 + .../workspace/message-thread-subscribers.ts | 103 ++++++++++++++++ .../workspace/message-threads.ts | 1 + .../enums/feature-flag-key.enum.ts | 1 + .../messaging/dtos/timeline-thread.dto.ts | 2 +- .../commands/add-standard-id.command.ts | 2 + .../constants/standard-field-ids.ts | 7 ++ .../constants/standard-object-ids.ts | 1 + .../standard-objects/index.ts | 2 + ...sage-thread-subscriber.workspace-entity.ts | 58 +++++++++ .../message-thread.workspace-entity.ts | 23 +++- .../messaging-message-thread.service.ts | 21 ++++ .../workspace-member.workspace-entity.ts | 17 +++ 42 files changed, 929 insertions(+), 104 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/emails/components/EmailThreadMembersChip.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersChip.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/types/MessageThread.ts create mode 100644 packages/twenty-front/src/modules/activities/emails/types/MessageThreadSubscriber.ts create mode 100644 packages/twenty-front/src/modules/activities/right-drawer/components/MessageThreadSubscribersTopBar.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBarDropdownButton.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useListenRightDrawerClose.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/states/messageThreadState.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/states/rightDrawerHeaderDropdownButtonState.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/types/ComponentByRightDrawerPage.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent.ts create mode 100644 packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemAvatar.tsx create mode 100644 packages/twenty-server/src/database/typeorm-seeds/workspace/message-thread-subscribers.ts create mode 100644 packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity.ts diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMembersChip.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMembersChip.tsx new file mode 100644 index 000000000..192b1ed3a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMembersChip.tsx @@ -0,0 +1,16 @@ +import { MessageThreadSubscribersDropdownButton } from '@/activities/emails/components/MessageThreadSubscribersDropdownButton'; +import { MessageThread } from '@/activities/emails/types/MessageThread'; + +export const EmailThreadMembersChip = ({ + messageThread, +}: { + messageThread: MessageThread; +}) => { + const subscribers = messageThread.subscribers ?? []; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber.tsx new file mode 100644 index 000000000..9196dd26b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber.tsx @@ -0,0 +1,40 @@ +import { MessageThreadSubscriberDropdownAddSubscriberMenuItem } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem'; +import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; + +export const MessageThreadSubscriberDropdownAddSubscriber = ({ + existingSubscribers, +}: { + existingSubscribers: MessageThreadSubscriber[]; +}) => { + const { records: workspaceMembersLeftToAdd } = + useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + filter: { + not: { + id: { + in: existingSubscribers.map( + ({ workspaceMember }) => workspaceMember.id, + ), + }, + }, + }, + }); + + return ( + + + + {workspaceMembersLeftToAdd.map((workspaceMember) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx new file mode 100644 index 000000000..b56cdc080 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx @@ -0,0 +1,43 @@ +import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar'; +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; +import { IconPlus } from 'twenty-ui'; + +export const MessageThreadSubscriberDropdownAddSubscriberMenuItem = ({ + workspaceMember, +}: { + workspaceMember: WorkspaceMember; +}) => { + const text = `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`; + + const { createOneRecord } = useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.MessageThreadSubscriber, + }); + + const handleAddButtonClick = () => { + createOneRecord({ + workspaceMember, + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersChip.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersChip.tsx new file mode 100644 index 000000000..d10827215 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersChip.tsx @@ -0,0 +1,80 @@ +import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useContext } from 'react'; +import { + Avatar, + AvatarGroup, + Chip, + ChipVariant, + IconChevronDown, + ThemeContext, +} from 'twenty-ui'; + +const MAX_NUMBER_OF_AVATARS = 3; + +export const MessageThreadSubscribersChip = ({ + messageThreadSubscribers, +}: { + messageThreadSubscribers: MessageThreadSubscriber[]; +}) => { + const { theme } = useContext(ThemeContext); + + const numberOfMessageThreadSubscribers = messageThreadSubscribers.length; + + const isOnlyOneSubscriber = numberOfMessageThreadSubscribers === 1; + + const isPrivateThread = isOnlyOneSubscriber; + + const privateLabel = 'Private'; + + const susbcriberAvatarUrls = messageThreadSubscribers + .map((member) => member.workspaceMember.avatarUrl) + .filter(isNonEmptyString); + + const firstAvatarUrl = susbcriberAvatarUrls[0]; + const firstAvatarColorSeed = messageThreadSubscribers?.[0].workspaceMember.id; + const firstAvatarPlaceholder = + messageThreadSubscribers?.[0].workspaceMember.name.firstName; + + const subscriberNames = messageThreadSubscribers.map( + (member) => member.workspaceMember?.name.firstName, + ); + + const moreAvatarsLabel = + numberOfMessageThreadSubscribers > MAX_NUMBER_OF_AVATARS + ? `+${numberOfMessageThreadSubscribers - MAX_NUMBER_OF_AVATARS}` + : null; + + const label = isPrivateThread ? privateLabel : moreAvatarsLabel ?? ''; + + return ( + + ) : ( + ( + + ))} + /> + ) + } + rightComponent={} + clickable + /> + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx new file mode 100644 index 000000000..7f7c42e7a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx @@ -0,0 +1,110 @@ +import { offset } from '@floating-ui/react'; +import { IconMinus, IconPlus } from 'twenty-ui'; + +import { MessageThreadSubscriberDropdownAddSubscriber } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber'; +import { MessageThreadSubscribersChip } from '@/activities/emails/components/MessageThreadSubscribersChip'; +import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar'; +import { useState } from 'react'; + +export const MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID = + 'message-thread-subscriber'; + +export const MessageThreadSubscribersDropdownButton = ({ + messageThreadSubscribers, +}: { + messageThreadSubscribers: MessageThreadSubscriber[]; +}) => { + const [isAddingSubscriber, setIsAddingSubscriber] = useState(false); + + const { closeDropdown } = useDropdown(MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID); + + const mockSubscribers = [ + ...messageThreadSubscribers, + ...messageThreadSubscribers, + ...messageThreadSubscribers, + ...messageThreadSubscribers, + ]; + + // TODO: implement + const handleAddSubscriberClick = () => { + setIsAddingSubscriber(true); + }; + + // TODO: implement + const handleRemoveSubscriber = (_subscriber: MessageThreadSubscriber) => { + closeDropdown(); + }; + + useListenRightDrawerClose(() => { + closeDropdown(); + }); + + return ( + + } + dropdownComponents={ + + {isAddingSubscriber ? ( + + ) : ( + + {messageThreadSubscribers?.map((subscriber) => ( + { + handleRemoveSubscriber(subscriber); + }} + text={ + subscriber.workspaceMember.name.firstName + + ' ' + + subscriber.workspaceMember.name.lastName + } + avatar={{ + placeholder: subscriber.workspaceMember.name.firstName, + avatarUrl: subscriber.workspaceMember.avatarUrl, + placeholderColorSeed: subscriber.workspaceMember.id, + size: 'md', + type: 'rounded', + }} + iconButtons={[ + { + Icon: IconMinus, + onClick: () => { + handleRemoveSubscriber(subscriber); + }, + }, + ]} + /> + ))} + + + + )} + + } + dropdownHotkeyScope={{ + scope: MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID, + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory.ts b/packages/twenty-front/src/modules/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory.ts index a634721a1..d141d867b 100644 --- a/packages/twenty-front/src/modules/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory.ts @@ -2,7 +2,13 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory'; export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperationSignatureFactory = - ({ messageThreadId }: { messageThreadId: string }) => ({ + ({ + messageThreadId, + isSubscribersEnabled, + }: { + messageThreadId: string; + isSubscribersEnabled: boolean; + }) => ({ objectNameSingular: CoreObjectNameSingular.Message, variables: { filter: { @@ -25,6 +31,18 @@ export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperation subject: true, text: true, receivedAt: true, + messageThread: { + id: true, + subscribers: isSubscribersEnabled + ? { + workspaceMember: { + id: true, + name: true, + avatarUrl: true, + }, + } + : undefined, + }, messageParticipants: { id: true, role: true, diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx index 09c98810b..a03223780 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; -import { useRecoilCallback } from 'recoil'; +import { useEffect, useMemo } from 'react'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { EmailLoader } from '@/activities/emails/components/EmailLoader'; @@ -8,8 +9,8 @@ import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMe import { IntermediaryMessages } from '@/activities/emails/right-drawer/components/IntermediaryMessages'; import { useRightDrawerEmailThread } from '@/activities/emails/right-drawer/hooks/useRightDrawerEmailThread'; import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState'; -import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; +import { messageThreadState } from '@/ui/layout/right-drawer/states/messageThreadState'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; const StyledContainer = styled.div` @@ -22,21 +23,31 @@ const StyledContainer = styled.div` position: relative; `; -const getVisibleMessages = (messages: EmailThreadMessageType[]) => - messages.filter(({ messageParticipants }) => { - const from = messageParticipants.find( - (participant) => participant.role === 'from', - ); - const receivers = messageParticipants.filter( - (participant) => participant.role !== 'from', - ); - return from && receivers.length > 0; - }); - export const RightDrawerEmailThread = () => { + const setMessageThread = useSetRecoilState(messageThreadState); + const { thread, messages, fetchMoreMessages, loading } = useRightDrawerEmailThread(); + const visibleMessages = useMemo(() => { + return messages.filter(({ messageParticipants }) => { + const from = messageParticipants.find( + (participant) => participant.role === 'from', + ); + const receivers = messageParticipants.filter( + (participant) => participant.role !== 'from', + ); + return from && receivers.length > 0; + }); + }, [messages]); + + useEffect(() => { + if (!visibleMessages[0]?.messageThread) { + return; + } + setMessageThread(visibleMessages[0]?.messageThread); + }); + const { useRegisterClickOutsideListenerCallback } = useClickOutsideListener( RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID, ); @@ -60,7 +71,6 @@ export const RightDrawerEmailThread = () => { return null; } - const visibleMessages = getVisibleMessages(messages); const visibleMessagesCount = visibleMessages.length; const is5OrMoreMessages = visibleMessagesCount >= 5; const firstMessages = visibleMessages.slice( diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts index 47c30f4d0..da57c1c4d 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts @@ -3,12 +3,13 @@ import { useRecoilValue } from 'recoil'; import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory'; import { EmailThread } from '@/activities/emails/types/EmailThread'; -import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; +import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; export const useRightDrawerEmailThread = () => { const viewableRecordId = useRecoilValue(viewableRecordIdState); @@ -20,19 +21,26 @@ export const useRightDrawerEmailThread = () => { recordGqlFields: { id: true, }, - onCompleted: (record) => upsertRecords([record]), + onCompleted: (record) => { + upsertRecords([record]); + }, }); + const isMessageThreadSubscribersEnabled = useIsFeatureEnabled( + 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', + ); + const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE = fetchAllThreadMessagesOperationSignatureFactory({ messageThreadId: viewableRecordId, + isSubscribersEnabled: isMessageThreadSubscribersEnabled, }); const { records: messages, loading, fetchMoreRecords, - } = useFindManyRecords({ + } = useFindManyRecords({ limit: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.limit, filter: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.filter, objectNameSingular: diff --git a/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts b/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts index 01f6555b9..80584c074 100644 --- a/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts +++ b/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts @@ -1,4 +1,5 @@ import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; +import { MessageThread } from '@/activities/emails/types/MessageThread'; export type EmailThreadMessage = { id: string; @@ -7,5 +8,6 @@ export type EmailThreadMessage = { subject: string; messageThreadId: string; messageParticipants: EmailThreadMessageParticipant[]; + messageThread: MessageThread; __typename: 'EmailThreadMessage'; }; diff --git a/packages/twenty-front/src/modules/activities/emails/types/MessageThread.ts b/packages/twenty-front/src/modules/activities/emails/types/MessageThread.ts new file mode 100644 index 000000000..b853f8328 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/types/MessageThread.ts @@ -0,0 +1,6 @@ +import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; + +export type MessageThread = { + id: string; + subscribers?: MessageThreadSubscriber[]; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/types/MessageThreadSubscriber.ts b/packages/twenty-front/src/modules/activities/emails/types/MessageThreadSubscriber.ts new file mode 100644 index 000000000..7f13c279d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/types/MessageThreadSubscriber.ts @@ -0,0 +1,7 @@ +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; + +export type MessageThreadSubscriber = { + __typename: 'MessageThreadSubscriber'; + id: string; + workspaceMember: WorkspaceMember; +}; diff --git a/packages/twenty-front/src/modules/activities/right-drawer/components/MessageThreadSubscribersTopBar.tsx b/packages/twenty-front/src/modules/activities/right-drawer/components/MessageThreadSubscribersTopBar.tsx new file mode 100644 index 000000000..5c85aece3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/right-drawer/components/MessageThreadSubscribersTopBar.tsx @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { EmailThreadMembersChip } from '@/activities/emails/components/EmailThreadMembersChip'; +import { messageThreadState } from '@/ui/layout/right-drawer/states/messageThreadState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from 'twenty-ui'; + +const StyledButtonContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +export const MessageThreadSubscribersTopBar = () => { + const isMessageThreadSubscriberEnabled = useIsFeatureEnabled( + 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', + ); + + const messageThread = useRecoilValue(messageThreadState); + + const numberOfSubscribers = messageThread?.subscribers?.length ?? 0; + + const shouldShowMembersChip = numberOfSubscribers > 0; + + if ( + !isMessageThreadSubscriberEnabled || + !isDefined(messageThread) || + !shouldShowMembersChip + ) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 1148c99dd..f0b76f473 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -27,4 +27,5 @@ export enum CoreObjectNameSingular { ViewSort = 'viewSort', Webhook = 'webhook', WorkspaceMember = 'workspaceMember', + MessageThreadSubscriber = 'messageThreadSubscriber', } diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx index 537c2fd08..2c609e0e3 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx @@ -3,13 +3,12 @@ import { SingleEntitySelectMenuItems, SingleEntitySelectMenuItemsProps, } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItems'; +import { useEntitySelectSearch } from '@/object-record/relation-picker/hooks/useEntitySelectSearch'; import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { isDefined } from '~/utils/isDefined'; -import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch'; - export type SingleEntitySelectMenuItemsWithSearchProps = { excludedRelationRecordIds?: string[]; onCreate?: ((searchInput?: string) => void) | (() => void); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts index 7712852c8..129d93f1d 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts @@ -3,6 +3,7 @@ import { useRecoilState } from 'recoil'; import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; +import { useCallback } from 'react'; import { isDefined } from '~/utils/isDefined'; export const useDropdown = (dropdownId?: string) => { @@ -27,10 +28,10 @@ export const useDropdown = (dropdownId?: string) => { const [isDropdownOpen, setIsDropdownOpen] = useRecoilState(isDropdownOpenState); - const closeDropdown = () => { + const closeDropdown = useCallback(() => { goBackToPreviousHotkeyScope(); setIsDropdownOpen(false); - }; + }, [goBackToPreviousHotkeyScope, setIsDropdownOpen]); const openDropdown = () => { setIsDropdownOpen(true); diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index 18eaf4aa4..529b0d1f7 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -25,6 +25,7 @@ import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState'; import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { RightDrawerHotkeyScope } from '../types/RightDrawerHotkeyScope'; +import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent'; import { RightDrawerRouter } from './RightDrawerRouter'; const StyledContainer = styled(motion.div)` @@ -47,6 +48,41 @@ const StyledRightDrawer = styled.div` `; export const RightDrawer = () => { + const theme = useTheme(); + + const animationVariants = { + fullScreen: { + x: '0%', + width: '100%', + height: '100%', + bottom: '0', + top: '0', + }, + normal: { + x: '0%', + width: theme.rightDrawerWidth, + height: '100%', + bottom: '0', + top: '0', + }, + closed: { + x: '100%', + width: '0', + height: '100%', + bottom: '0', + top: 'auto', + }, + minimized: { + x: '0%', + width: 220, + height: 41, + bottom: '0', + top: 'auto', + }, + }; + + type RightDrawerAnimationVariant = keyof typeof animationVariants; + const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState( isRightDrawerOpenState, ); @@ -82,6 +118,8 @@ export const RightDrawer = () => { if (isRightDrawerOpen && !isRightDrawerMinimized) { set(rightDrawerCloseEventState, event); closeRightDrawer(); + + emitRightDrawerCloseEvent(); } }, [closeRightDrawer], @@ -89,11 +127,8 @@ export const RightDrawer = () => { mode: ClickOutsideMode.comparePixels, }); - const theme = useTheme(); - useScopedHotkeys( [Key.Escape], - () => { closeRightDrawer(); }, @@ -103,56 +138,27 @@ export const RightDrawer = () => { const isMobile = useIsMobile(); - const rightDrawerWidth = isRightDrawerOpen - ? isMobile - ? '100%' - : theme.rightDrawerWidth - : '0'; + const targetVariantForAnimation: RightDrawerAnimationVariant = + !isRightDrawerOpen + ? 'closed' + : isRightDrawerMinimized + ? 'minimized' + : isMobile + ? 'fullScreen' + : 'normal'; + + const handleAnimationComplete = () => { + setIsRightDrawerAnimationCompleted(isRightDrawerOpen); + }; if (!isDefined(rightDrawerPage)) { return <>; } - const variants = { - fullScreen: { - x: '0%', - }, - normal: { - x: '0%', - width: rightDrawerWidth, - }, - closed: { - x: '100%', - }, - minimized: { - x: '0%', - width: 'auto', - height: 'auto', - bottom: '0', - top: 'auto', - }, - }; - const handleAnimationComplete = () => { - setIsRightDrawerAnimationCompleted(isRightDrawerOpen); - }; - return ( , - topBar: , - }, - [RightDrawerPages.ViewCalendarEvent]: { - page: , - topBar: , - }, - [RightDrawerPages.ViewRecord]: { - page: , - topBar: , - }, - [RightDrawerPages.Copilot]: { - page: , - topBar: , - }, +const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = { + [RightDrawerPages.ViewEmailThread]: , + [RightDrawerPages.ViewCalendarEvent]: , + [RightDrawerPages.ViewRecord]: , + [RightDrawerPages.Copilot]: , }; export const RightDrawerRouter = () => { const [rightDrawerPage] = useRecoilState(rightDrawerPageState); - const { topBar = null, page = null } = rightDrawerPage - ? RIGHT_DRAWER_PAGES_CONFIG[rightDrawerPage] - : {}; + const rightDrawerPageComponent = isDefined(rightDrawerPage) ? ( + RIGHT_DRAWER_PAGES_CONFIG[rightDrawerPage] + ) : ( + <> + ); const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState); return ( - {topBar} + {!isRightDrawerMinimized && ( - {page} + + {rightDrawerPageComponent} + )} ); diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx index 64eddfd9b..9176af203 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx @@ -8,16 +8,19 @@ import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShow import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState'; import { RightDrawerTopBarCloseButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarCloseButton'; +import { RightDrawerTopBarDropdownButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarDropdownButton'; import { RightDrawerTopBarExpandButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarExpandButton'; import { RightDrawerTopBarMinimizeButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarMinimizeButton'; import { StyledRightDrawerTopBar } from '@/ui/layout/right-drawer/components/StyledRightDrawerTopBar'; import { RIGHT_DRAWER_PAGE_ICONS } from '@/ui/layout/right-drawer/constants/RightDrawerPageIcons'; import { RIGHT_DRAWER_PAGE_TITLES } from '@/ui/layout/right-drawer/constants/RightDrawerPageTitles'; import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; +import { rightDrawerPageState } from '@/ui/layout/right-drawer/states/rightDrawerPageState'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; const StyledTopBarWrapper = styled.div` + align-items: center; display: flex; `; @@ -40,9 +43,11 @@ const StyledMinimizeTopBarIcon = styled.div` display: flex; `; -export const RightDrawerTopBar = ({ page }: { page: RightDrawerPages }) => { +export const RightDrawerTopBar = () => { const isMobile = useIsMobile(); + const rightDrawerPage = useRecoilValue(rightDrawerPageState); + const [isRightDrawerMinimized, setIsRightDrawerMinimized] = useRecoilState( isRightDrawerMinimizedState, ); @@ -57,8 +62,6 @@ export const RightDrawerTopBar = ({ page }: { page: RightDrawerPages }) => { const { getIcon } = useIcons(); - const PageIcon = getIcon(RIGHT_DRAWER_PAGE_ICONS[page]); - const viewableRecordNameSingular = useRecoilValue( viewableRecordNameSingularState, ); @@ -69,14 +72,21 @@ export const RightDrawerTopBar = ({ page }: { page: RightDrawerPages }) => { objectNameSingular: viewableRecordNameSingular ?? 'company', }); + if (!rightDrawerPage) { + return null; + } + + const PageIcon = getIcon(RIGHT_DRAWER_PAGE_ICONS[rightDrawerPage]); + const ObjectIcon = getIcon(objectMetadataItem.icon); const label = - page === RightDrawerPages.ViewRecord + rightDrawerPage === RightDrawerPages.ViewRecord ? objectMetadataItem.labelSingular - : RIGHT_DRAWER_PAGE_TITLES[page]; + : RIGHT_DRAWER_PAGE_TITLES[rightDrawerPage]; - const Icon = page === RightDrawerPages.ViewRecord ? ObjectIcon : PageIcon; + const Icon = + rightDrawerPage === RightDrawerPages.ViewRecord ? ObjectIcon : PageIcon; return ( { )} + {!isMobile && !isRightDrawerMinimized && ( )} diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBarDropdownButton.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBarDropdownButton.tsx new file mode 100644 index 000000000..823d6bdbf --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBarDropdownButton.tsx @@ -0,0 +1,27 @@ +import { MessageThreadSubscribersTopBar } from '@/activities/right-drawer/components/MessageThreadSubscribersTopBar'; +import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; +import { rightDrawerPageState } from '@/ui/layout/right-drawer/states/rightDrawerPageState'; +import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { useRecoilState } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +const RIGHT_DRAWER_TOP_BAR_DROPDOWN_BUTTON_CONFIG: ComponentByRightDrawerPage = + { + [RightDrawerPages.ViewEmailThread]: , + }; + +export const RightDrawerTopBarDropdownButton = () => { + const [isRightDrawerMinimized] = useRecoilState(isRightDrawerMinimizedState); + + const [rightDrawerPage] = useRecoilState(rightDrawerPageState); + + if (isRightDrawerMinimized || !isDefined(rightDrawerPage)) { + return null; + } + + const dropdownButtonComponent = + RIGHT_DRAWER_TOP_BAR_DROPDOWN_BUTTON_CONFIG[rightDrawerPage]; + + return dropdownButtonComponent ?? <>; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useListenRightDrawerClose.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useListenRightDrawerClose.ts new file mode 100644 index 000000000..29fc73d53 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useListenRightDrawerClose.ts @@ -0,0 +1,12 @@ +import { RIGHT_DRAWER_CLOSE_EVENT_NAME } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent'; +import { useEffect } from 'react'; + +export const useListenRightDrawerClose = (callback: () => void) => { + useEffect(() => { + window.addEventListener(RIGHT_DRAWER_CLOSE_EVENT_NAME, callback); + + return () => { + window.removeEventListener(RIGHT_DRAWER_CLOSE_EVENT_NAME, callback); + }; + }, [callback]); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/states/messageThreadState.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/states/messageThreadState.ts new file mode 100644 index 000000000..2e1febcf6 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/states/messageThreadState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +import { MessageThread } from '@/activities/emails/types/MessageThread'; + +export const messageThreadState = createState({ + key: 'messageThreadState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/states/rightDrawerHeaderDropdownButtonState.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/states/rightDrawerHeaderDropdownButtonState.ts new file mode 100644 index 000000000..a0f2a8496 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/states/rightDrawerHeaderDropdownButtonState.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui'; + +import { RightDrawerTopBarDropdownButtons } from '@/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons'; + +export const rightDrawerTopBarDropdownButtonState = + createState({ + key: 'rightDrawerTopBarDropdownButtonState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/ComponentByRightDrawerPage.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/ComponentByRightDrawerPage.ts new file mode 100644 index 000000000..bcef77e8c --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/ComponentByRightDrawerPage.ts @@ -0,0 +1,5 @@ +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; + +export type ComponentByRightDrawerPage = { + [componentName in RightDrawerPages]?: JSX.Element; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons.ts new file mode 100644 index 000000000..b76d5b399 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons.ts @@ -0,0 +1,3 @@ +export enum RightDrawerTopBarDropdownButtons { + EmailThreadSubscribers = 'EmailThreadSubscribers', +} diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent.ts new file mode 100644 index 000000000..e147a2f2f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent.ts @@ -0,0 +1,5 @@ +export const RIGHT_DRAWER_CLOSE_EVENT_NAME = 'right-drawer-close'; + +export const emitRightDrawerCloseEvent = () => { + window.dispatchEvent(new CustomEvent(RIGHT_DRAWER_CLOSE_EVENT_NAME)); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItem.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItem.tsx index a85712807..5ff957ea0 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItem.tsx @@ -1,5 +1,5 @@ -import { FunctionComponent, MouseEvent, ReactElement, ReactNode } from 'react'; import { useTheme } from '@emotion/react'; +import { FunctionComponent, MouseEvent, ReactElement, ReactNode } from 'react'; import { IconChevronRight, IconComponent } from 'twenty-ui'; import { LightIconButtonProps } from '@/ui/input/button/components/LightIconButton'; @@ -76,7 +76,6 @@ export const MenuItem = ({ )} - {hasSubMenu && ( ; + Icon: IconComponent; + accent?: LightIconButtonProps['accent']; + onClick?: (event: MouseEvent) => void; +}; + +export type MenuItemAvatarProps = { + accent?: MenuItemAccent; + className?: string; + iconButtons?: MenuItemIconButton[]; + isIconDisplayedOnHoverOnly?: boolean; + isTooltipOpen?: boolean; + avatar?: Pick< + AvatarProps, + 'avatarUrl' | 'placeholderColorSeed' | 'placeholder' | 'size' | 'type' + > | null; + onClick?: (event: MouseEvent) => void; + onMouseEnter?: (event: MouseEvent) => void; + onMouseLeave?: (event: MouseEvent) => void; + testId?: string; + text: string; + hasSubMenu?: boolean; +}; + +// TODO: merge with MenuItem +export const MenuItemAvatar = ({ + accent = 'default', + className, + iconButtons, + isIconDisplayedOnHoverOnly = true, + onClick, + onMouseEnter, + onMouseLeave, + testId, + avatar, + hasSubMenu = false, + text, +}: MenuItemAvatarProps) => { + const theme = useTheme(); + const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0; + + const handleMenuItemClick = (event: MouseEvent) => { + if (!onClick) return; + event.preventDefault(); + event.stopPropagation(); + + onClick?.(event); + }; + + return ( + + + {isDefined(avatar) && ( + + )} + + +
+ {showIconButtons && ( + + )} +
+ {hasSubMenu && ( + + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index bbcca44a7..53f29d4b2 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -7,4 +7,5 @@ export type FeatureFlagKey = | 'IS_STRIPE_INTEGRATION_ENABLED' | 'IS_FUNCTION_SETTINGS_ENABLED' | 'IS_COPILOT_ENABLED' - | 'IS_CRM_MIGRATION_ENABLED'; + | 'IS_CRM_MIGRATION_ENABLED' + | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'; diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 57cf1fa56..b701093e7 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -15,6 +15,7 @@ import { seedConnectedAccount } from 'src/database/typeorm-seeds/workspace/conne import { seedMessageChannelMessageAssociation } from 'src/database/typeorm-seeds/workspace/message-channel-message-associations'; import { seedMessageChannel } from 'src/database/typeorm-seeds/workspace/message-channels'; import { seedMessageParticipant } from 'src/database/typeorm-seeds/workspace/message-participants'; +import { seedMessageThreadSubscribers } from 'src/database/typeorm-seeds/workspace/message-thread-subscribers'; import { seedMessageThread } from 'src/database/typeorm-seeds/workspace/message-threads'; import { seedMessage } from 'src/database/typeorm-seeds/workspace/messages'; import { seedOpportunity } from 'src/database/typeorm-seeds/workspace/opportunities'; @@ -22,6 +23,8 @@ import { seedPeople } from 'src/database/typeorm-seeds/workspace/people'; import { seedWorkspaceMember } from 'src/database/typeorm-seeds/workspace/workspace-members'; import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { CacheStorageService } from 'src/engine/integrations/cache-storage/cache-storage.service'; import { InjectCacheStorage } from 'src/engine/integrations/cache-storage/decorators/cache-storage.decorator'; import { CacheStorageNamespace } from 'src/engine/integrations/cache-storage/types/cache-storage-namespace.enum'; @@ -117,6 +120,11 @@ export class DataSeedWorkspaceCommand extends CommandRunner { return acc; }, {}); + const featureFlagRepository = + workspaceDataSource.getRepository('featureFlag'); + + const featureFlags = await featureFlagRepository.find({}); + await workspaceDataSource.transaction( async (entityManager: EntityManager) => { await seedCompanies(entityManager, dataSourceMetadata.schema); @@ -134,6 +142,21 @@ export class DataSeedWorkspaceCommand extends CommandRunner { entityManager, dataSourceMetadata.schema, ); + + const isMessageThreadSubscriberEnabled = featureFlags.some( + (featureFlag) => + featureFlag.key === + FeatureFlagKey.IsMessageThreadSubscriberEnabled && + featureFlag.value === true, + ); + + if (isMessageThreadSubscriberEnabled) { + await seedMessageThreadSubscribers( + entityManager, + dataSourceMetadata.schema, + ); + } + await seedMessage(entityManager, dataSourceMetadata.schema); await seedMessageChannel( entityManager, diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 0fec06b89..dd0be3fa8 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -60,6 +60,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IsMessageThreadSubscriberEnabled, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/database/typeorm-seeds/workspace/message-thread-subscribers.ts b/packages/twenty-server/src/database/typeorm-seeds/workspace/message-thread-subscribers.ts new file mode 100644 index 000000000..637446cd9 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm-seeds/workspace/message-thread-subscribers.ts @@ -0,0 +1,103 @@ +import { EntityManager } from 'typeorm'; + +const tableName = 'messageThreadSubscriber'; + +export const DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS = { + MESSAGE_THREAD_SUBSCRIBER_1: '20202020-cc69-44ef-a82c-600c0dbf39ba', + MESSAGE_THREAD_SUBSCRIBER_2: '20202020-d80e-4a13-b10b-72ba09082668', + MESSAGE_THREAD_SUBSCRIBER_3: '20202020-e6ec-4c8a-b431-0901eaf395a9', + MESSAGE_THREAD_SUBSCRIBER_4: '20202020-1455-4c57-afaf-dd5dc086361d', + MESSAGE_THREAD_SUBSCRIBER_5: '20202020-f79e-40dd-bd06-c36e6abb4678', + MESSAGE_THREAD_SUBSCRIBER_6: '20202020-3ec3-4fe3-8997-b76aa0bfa408', + MESSAGE_THREAD_SUBSCRIBER_7: '20202020-c21e-4ec2-873b-de4264d89025', +}; + +export const DEV_SEED_MESSAGE_THREAD_IDS = { + MESSAGE_THREAD_1: '20202020-8bfa-453b-b99b-bc435a7d4da8', + MESSAGE_THREAD_2: '20202020-634a-4fde-aa7c-28a0eaf203ca', + MESSAGE_THREAD_3: '20202020-1b56-4f10-a2fa-2ccaddf81f6c', + MESSAGE_THREAD_4: '20202020-d51c-485a-b1b6-ed7c63e05d72', +}; + +export const DEV_SEED_USER_IDS = { + TIM: '20202020-0687-4c41-b707-ed1bfca972a7', + PHIL: '20202020-1553-45c6-a028-5a9064cce07f', + JONY: '20202020-77d5-4cb6-b60a-f4a835a85d61', +}; + +export const seedMessageThreadSubscribers = async ( + entityManager: EntityManager, + schemaName: string, +) => { + await entityManager + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, [ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'messageThreadId', + 'workspaceMemberId', + ]) + .orIgnore() + .values([ + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_1, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_1, + workspaceMemberId: DEV_SEED_USER_IDS.PHIL, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_2, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_1, + workspaceMemberId: DEV_SEED_USER_IDS.JONY, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_3, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_2, + workspaceMemberId: DEV_SEED_USER_IDS.TIM, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_4, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_3, + workspaceMemberId: DEV_SEED_USER_IDS.JONY, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_5, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_4, + workspaceMemberId: DEV_SEED_USER_IDS.TIM, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_6, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_4, + workspaceMemberId: DEV_SEED_USER_IDS.PHIL, + }, + { + id: DEV_SEED_MESSAGE_THREAD_SUBSCRIBERS_IDS.MESSAGE_THREAD_SUBSCRIBER_7, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + messageThreadId: DEV_SEED_MESSAGE_THREAD_IDS.MESSAGE_THREAD_4, + workspaceMemberId: DEV_SEED_USER_IDS.JONY, + }, + ]) + .execute(); +}; diff --git a/packages/twenty-server/src/database/typeorm-seeds/workspace/message-threads.ts b/packages/twenty-server/src/database/typeorm-seeds/workspace/message-threads.ts index 42a2f14fc..a7d8ea1df 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/workspace/message-threads.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/workspace/message-threads.ts @@ -7,6 +7,7 @@ export const DEV_SEED_MESSAGE_THREAD_IDS = { MESSAGE_THREAD_2: '20202020-634a-4fde-aa7c-28a0eaf203ca', MESSAGE_THREAD_3: '20202020-1b56-4f10-a2fa-2ccaddf81f6c', MESSAGE_THREAD_4: '20202020-d51c-485a-b1b6-ed7c63e05d72', + MESSAGE_THREAD_5: '20202020-3f74-492d-a101-2a70f50a1645', }; export const seedMessageThread = async ( diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 05bda801f..fd30ef5bd 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -10,4 +10,5 @@ export enum FeatureFlagKey { IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED', IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED', IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED', + IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/messaging/dtos/timeline-thread.dto.ts b/packages/twenty-server/src/engine/core-modules/messaging/dtos/timeline-thread.dto.ts index 34b94a056..431f00d5a 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/dtos/timeline-thread.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/dtos/timeline-thread.dto.ts @@ -1,4 +1,4 @@ -import { ObjectType, Field, registerEnumType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { TimelineThreadParticipant } from 'src/engine/core-modules/messaging/dtos/timeline-thread-participant.dto'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts index 07e5efc09..de3758b26 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts @@ -64,6 +64,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_FREE_ACCESS_ENABLED: false, IS_FUNCTION_SETTINGS_ENABLED: false, IS_WORKFLOW_ENABLED: false, + IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED: false, }, ); const standardFieldMetadataCollection = this.standardFieldFactory.create( @@ -84,6 +85,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_FREE_ACCESS_ENABLED: false, IS_FUNCTION_SETTINGS_ENABLED: false, IS_WORKFLOW_ENABLED: false, + IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED: false, }, ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 73de80046..c1c13755b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -244,6 +244,12 @@ export const MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS = { export const MESSAGE_THREAD_STANDARD_FIELD_IDS = { messages: '20202020-3115-404f-aade-e1154b28e35a', messageChannelMessageAssociations: '20202020-314e-40a4-906d-a5d5d6c285f6', + messageThreadSubscribers: '20202020-3b3b-4b3b-8b3b-7f8d6a1d7d5b', +}; + +export const MESSAGE_THREAD_SUBSCRIBER_STANDARD_FIELD_IDS = { + messageThread: '20202020-2c8f-4f3e-8b9a-7f8d6a1c7d5b', + workspaceMember: '20202020-7f7b-4b3b-8b3b-7f8d6a1d7d5a', }; export const MESSAGE_STANDARD_FIELD_IDS = { @@ -418,6 +424,7 @@ export const WORKSPACE_MEMBER_STANDARD_FIELD_IDS = { calendarEventParticipants: '20202020-0dbc-4841-9ce1-3e793b5b3512', timelineActivities: '20202020-e15b-47b8-94fe-8200e3c66615', auditLogs: '20202020-2f54-4739-a5e2-99563385e83d', + messageThreadSubscribers: '20202020-4b3b-4b3b-9b3b-3b3b3b3b3b3b', timeZone: '20202020-2d33-4c21-a86e-5943b050dd54', dateFormat: '20202020-af13-4e11-b1e7-b8cf5ea13dc0', timeFormat: '20202020-8acb-4cf8-a851-a6ed443c8d81', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts index d0f4042b9..7be15073d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts @@ -25,6 +25,7 @@ export const STANDARD_OBJECT_IDS = { messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7', messageParticipant: '20202020-a433-4456-aa2d-fd9cb26b774a', messageThread: '20202020-849a-4c3e-84f5-a25a7d802271', + messageThreadSubscriber: '20202020-4b3b-4b3b-8b3b-3b3b3b3b3b3a', message: '20202020-3f6b-4425-80ab-e468899ab4b2', note: '20202020-0b00-45cd-b6f6-6cd806fc6804', noteTarget: '20202020-fff0-4b44-be82-bda313884400', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index 9a42b4dd7..95c148669 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -14,6 +14,7 @@ import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/f import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; +import { MessageThreadSubscriberWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity'; import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity'; import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity'; import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity'; @@ -65,6 +66,7 @@ export const standardObjectMetadataDefinitions = [ WorkflowVersionWorkspaceEntity, WorkspaceMemberWorkspaceEntity, MessageThreadWorkspaceEntity, + MessageThreadSubscriberWorkspaceEntity, MessageWorkspaceEntity, MessageChannelWorkspaceEntity, MessageParticipantWorkspaceEntity, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity.ts new file mode 100644 index 000000000..05f052c42 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity.ts @@ -0,0 +1,58 @@ +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; +import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; +import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { MESSAGE_THREAD_SUBSCRIBER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@WorkspaceEntity({ + standardId: STANDARD_OBJECT_IDS.messageThreadSubscriber, + namePlural: 'messageThreadSubscriber', + labelSingular: 'Message Thread Subscriber', + labelPlural: 'Message Threads Subscribers', + description: 'Message Thread Subscribers', + icon: 'IconPerson', +}) +@WorkspaceIsNotAuditLogged() +@WorkspaceIsSystem() +@WorkspaceGate({ + featureFlag: FeatureFlagKey.IsMessageThreadSubscriberEnabled, +}) +export class MessageThreadSubscriberWorkspaceEntity extends BaseWorkspaceEntity { + @WorkspaceRelation({ + standardId: MESSAGE_THREAD_SUBSCRIBER_STANDARD_FIELD_IDS.messageThread, + type: RelationMetadataType.MANY_TO_ONE, + label: 'Message Thread', + description: 'Message Thread', + icon: 'IconMessage', + inverseSideFieldKey: 'subscribers', + inverseSideTarget: () => MessageThreadWorkspaceEntity, + }) + messageThread: Relation; + + @WorkspaceJoinColumn('messageThread') + messageThreadId: string; + + @WorkspaceRelation({ + standardId: MESSAGE_THREAD_SUBSCRIBER_STANDARD_FIELD_IDS.workspaceMember, + type: RelationMetadataType.MANY_TO_ONE, + label: 'Workspace Member', + description: 'Workspace Member that is part of the message thread', + icon: 'IconCircleUser', + inverseSideFieldKey: 'messageThreadSubscribers', + inverseSideTarget: () => WorkspaceMemberWorkspaceEntity, + }) + workspaceMember: Relation; + + @WorkspaceJoinColumn('workspaceMember') + workspaceMemberId: string; +} diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts index 64b81723f..46b9f5c02 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts @@ -1,18 +1,21 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { RelationMetadataType, RelationOnDeleteAction, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -import { MESSAGE_THREAD_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; -import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { MESSAGE_THREAD_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; +import { MessageThreadSubscriberWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity'; import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity'; @WorkspaceEntity({ @@ -38,6 +41,20 @@ export class MessageThreadWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsNullable() messages: Relation; + @WorkspaceRelation({ + standardId: MESSAGE_THREAD_STANDARD_FIELD_IDS.messageThreadSubscribers, + type: RelationMetadataType.ONE_TO_MANY, + label: 'Message Thread Subscribers', + description: 'Message Thread Subscribers', + icon: 'IconMessage', + inverseSideTarget: () => MessageThreadSubscriberWorkspaceEntity, + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceGate({ + featureFlag: FeatureFlagKey.IsMessageThreadSubscriberEnabled, + }) + subscribers: Relation; + @WorkspaceRelation({ standardId: MESSAGE_THREAD_STANDARD_FIELD_IDS.messageChannelMessageAssociations, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message-thread.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message-thread.service.ts index 35c575638..c59c456a2 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message-thread.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message-thread.service.ts @@ -4,16 +4,19 @@ import { EntityManager } from 'typeorm'; import { v4 } from 'uuid'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository'; import { MessageThreadRepository } from 'src/modules/messaging/common/repositories/message-thread.repository'; import { MessageRepository } from 'src/modules/messaging/common/repositories/message.repository'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; +import { MessageThreadSubscriberWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity'; import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity'; import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity'; @Injectable() export class MessagingMessageThreadService { constructor( + private readonly twentyORMManager: TwentyORMManager, @InjectObjectMetadataRepository( MessageChannelMessageAssociationWorkspaceEntity, ) @@ -24,6 +27,24 @@ export class MessagingMessageThreadService { private readonly messageThreadRepository: MessageThreadRepository, ) {} + public async saveMessageThreadMember( + messageThreadId: string, + workspaceMemberId: string, + ) { + const id = v4(); + + const messageThreadSubscriberRepository = + await this.twentyORMManager.getRepository( + 'messageThreadSubscriber', + ); + + await messageThreadSubscriberRepository.insert({ + id, + messageThreadId, + workspaceMemberId, + }); + } + public async saveMessageThreadOrReturnExistingMessageThread( headerMessageId: string, messageThreadExternalId: string, diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index 212250153..83371beb3 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { @@ -11,6 +12,7 @@ import { import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; @@ -26,6 +28,7 @@ import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/com import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; +import { MessageThreadSubscriberWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread-subscriber.workspace-entity'; import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity'; import { AuditLogWorkspaceEntity } from 'src/modules/timeline/standard-objects/audit-log.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; @@ -170,6 +173,20 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity { }) favorites: Relation; + @WorkspaceRelation({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.messageThreadSubscribers, + type: RelationMetadataType.ONE_TO_MANY, + label: 'Message thread subscribers', + description: 'Message thread subscribers for this workspace member', + icon: 'IconMessage', + inverseSideTarget: () => MessageThreadSubscriberWorkspaceEntity, + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceGate({ + featureFlag: FeatureFlagKey.IsMessageThreadSubscriberEnabled, + }) + messageThreadSubscribers: Relation; + @WorkspaceRelation({ standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.accountOwnerForCompanies, type: RelationMetadataType.ONE_TO_MANY,