Share an email thread to workspace members chip and dropdown (#4199) (#5640)

# 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 <simao.sanguinho@tecnico.ulisboa.pt>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
pereira0x 2024-07-31 17:50:27 +01:00 committed by GitHub
parent ae7821ce70
commit c3417ddba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 929 additions and 104 deletions

View File

@ -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 (
<MessageThreadSubscribersDropdownButton
messageThreadSubscribers={subscribers}
/>
);
};

View File

@ -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<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: {
not: {
id: {
in: existingSubscribers.map(
({ workspaceMember }) => workspaceMember.id,
),
},
},
},
});
return (
<DropdownMenuItemsContainer>
<DropdownMenuSearchInput />
<DropdownMenuSeparator />
{workspaceMembersLeftToAdd.map((workspaceMember) => (
<MessageThreadSubscriberDropdownAddSubscriberMenuItem
workspaceMember={workspaceMember}
/>
))}
</DropdownMenuItemsContainer>
);
};

View File

@ -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<MessageThreadSubscriber>({
objectNameSingular: CoreObjectNameSingular.MessageThreadSubscriber,
});
const handleAddButtonClick = () => {
createOneRecord({
workspaceMember,
});
};
return (
<MenuItemAvatar
avatar={{
placeholder: workspaceMember.name.firstName,
avatarUrl: workspaceMember.avatarUrl,
placeholderColorSeed: workspaceMember.id,
size: 'md',
type: 'rounded',
}}
text={text}
iconButtons={[
{
Icon: IconPlus,
onClick: handleAddButtonClick,
},
]}
/>
);
};

View File

@ -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 (
<Chip
label={label}
variant={ChipVariant.Highlighted}
leftComponent={
isOnlyOneSubscriber ? (
<Avatar
avatarUrl={firstAvatarUrl}
placeholderColorSeed={firstAvatarColorSeed}
placeholder={firstAvatarPlaceholder}
size="md"
type={'rounded'}
/>
) : (
<AvatarGroup
avatars={subscriberNames.map((name, index) => (
<Avatar
key={name}
avatarUrl={susbcriberAvatarUrls[index] ?? ''}
placeholder={name}
type="rounded"
/>
))}
/>
)
}
rightComponent={<IconChevronDown size={theme.icon.size.sm} />}
clickable
/>
);
};

View File

@ -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 (
<Dropdown
dropdownId={MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID}
clickableComponent={
<MessageThreadSubscribersChip
messageThreadSubscribers={mockSubscribers}
/>
}
dropdownComponents={
<DropdownMenu width="160px" z-index={offset(1)}>
{isAddingSubscriber ? (
<MessageThreadSubscriberDropdownAddSubscriber
existingSubscribers={messageThreadSubscribers}
/>
) : (
<DropdownMenuItemsContainer>
{messageThreadSubscribers?.map((subscriber) => (
<MenuItemAvatar
key={subscriber.workspaceMember.id}
testId="menu-item"
onClick={() => {
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);
},
},
]}
/>
))}
<DropdownMenuSeparator />
<MenuItem
LeftIcon={IconPlus}
onClick={handleAddSubscriberClick}
text="Add subscriber"
/>
</DropdownMenuItemsContainer>
)}
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID,
}}
/>
);
};

View File

@ -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,

View File

@ -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(

View File

@ -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<EmailThreadMessageType>({
} = useFindManyRecords<EmailThreadMessage>({
limit: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.limit,
filter: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.filter,
objectNameSingular:

View File

@ -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';
};

View File

@ -0,0 +1,6 @@
import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
export type MessageThread = {
id: string;
subscribers?: MessageThreadSubscriber[];
};

View File

@ -0,0 +1,7 @@
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export type MessageThreadSubscriber = {
__typename: 'MessageThreadSubscriber';
id: string;
workspaceMember: WorkspaceMember;
};

View File

@ -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 (
<StyledButtonContainer>
<EmailThreadMembersChip messageThread={messageThread} />
</StyledButtonContainer>
);
};

View File

@ -27,4 +27,5 @@ export enum CoreObjectNameSingular {
ViewSort = 'viewSort',
Webhook = 'webhook',
WorkspaceMember = 'workspaceMember',
MessageThreadSubscriber = 'messageThreadSubscriber',
}

View File

@ -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);

View File

@ -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);

View File

@ -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 (
<StyledContainer
initial={
isRightDrawerOpen
? isRightDrawerMinimized
? 'minimized'
: 'normal'
: 'closed'
}
animate={
isRightDrawerOpen
? isRightDrawerMinimized
? 'minimized'
: 'normal'
: 'closed'
}
variants={variants}
animate={targetVariantForAnimation}
variants={animationVariants}
transition={{
duration: theme.animation.duration.normal,
}}

View File

@ -5,9 +5,11 @@ import { RightDrawerCalendarEvent } from '@/activities/calendar/right-drawer/com
import { RightDrawerAIChat } from '@/activities/copilot/right-drawer/components/RightDrawerAIChat';
import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/components/RightDrawerEmailThread';
import { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage';
import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
@ -28,39 +30,31 @@ const StyledRightDrawerBody = styled.div`
position: relative;
`;
const RIGHT_DRAWER_PAGES_CONFIG = {
[RightDrawerPages.ViewEmailThread]: {
page: <RightDrawerEmailThread />,
topBar: <RightDrawerTopBar page={RightDrawerPages.ViewEmailThread} />,
},
[RightDrawerPages.ViewCalendarEvent]: {
page: <RightDrawerCalendarEvent />,
topBar: <RightDrawerTopBar page={RightDrawerPages.ViewCalendarEvent} />,
},
[RightDrawerPages.ViewRecord]: {
page: <RightDrawerRecord />,
topBar: <RightDrawerTopBar page={RightDrawerPages.ViewRecord} />,
},
[RightDrawerPages.Copilot]: {
page: <RightDrawerAIChat />,
topBar: <RightDrawerTopBar page={RightDrawerPages.Copilot} />,
},
const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
[RightDrawerPages.ViewEmailThread]: <RightDrawerEmailThread />,
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
[RightDrawerPages.Copilot]: <RightDrawerAIChat />,
};
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 (
<StyledRightDrawerPage>
{topBar}
<RightDrawerTopBar />
{!isRightDrawerMinimized && (
<StyledRightDrawerBody>{page}</StyledRightDrawerBody>
<StyledRightDrawerBody>
{rightDrawerPageComponent}
</StyledRightDrawerBody>
)}
</StyledRightDrawerPage>
);

View File

@ -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 (
<StyledRightDrawerTopBar
@ -101,6 +111,7 @@ export const RightDrawerTopBar = ({ page }: { page: RightDrawerPages }) => {
</StyledMinimizeTopBarTitleContainer>
)}
<StyledTopBarWrapper>
<RightDrawerTopBarDropdownButton />
{!isMobile && !isRightDrawerMinimized && (
<RightDrawerTopBarMinimizeButton />
)}

View File

@ -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]: <MessageThreadSubscribersTopBar />,
};
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 ?? <></>;
};

View File

@ -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]);
};

View File

@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
import { MessageThread } from '@/activities/emails/types/MessageThread';
export const messageThreadState = createState<MessageThread | null>({
key: 'messageThreadState',
defaultValue: null,
});

View File

@ -0,0 +1,9 @@
import { createState } from 'twenty-ui';
import { RightDrawerTopBarDropdownButtons } from '@/ui/layout/right-drawer/types/RightDrawerTopBarDropdownButtons';
export const rightDrawerTopBarDropdownButtonState =
createState<RightDrawerTopBarDropdownButtons | null>({
key: 'rightDrawerTopBarDropdownButtonState',
defaultValue: null,
});

View File

@ -0,0 +1,5 @@
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
export type ComponentByRightDrawerPage = {
[componentName in RightDrawerPages]?: JSX.Element;
};

View File

@ -0,0 +1,3 @@
export enum RightDrawerTopBarDropdownButtons {
EmailThreadSubscribers = 'EmailThreadSubscribers',
}

View File

@ -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));
};

View File

@ -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 = ({
<LightIconButtonGroup iconButtons={iconButtons} size="small" />
)}
</div>
{hasSubMenu && (
<IconChevronRight
size={theme.icon.size.sm}

View File

@ -0,0 +1,106 @@
import { useTheme } from '@emotion/react';
import { FunctionComponent, MouseEvent, ReactElement } from 'react';
import {
Avatar,
AvatarProps,
IconChevronRight,
IconComponent,
isDefined,
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { LightIconButtonProps } from '@/ui/input/button/components/LightIconButton';
import { LightIconButtonGroup } from '@/ui/input/button/components/LightIconButtonGroup';
import {
StyledHoverableMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
import { MenuItemAccent } from '../types/MenuItemAccent';
export type MenuItemIconButton = {
Wrapper?: FunctionComponent<{ iconButton: ReactElement }>;
Icon: IconComponent;
accent?: LightIconButtonProps['accent'];
onClick?: (event: MouseEvent<any>) => 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<HTMLDivElement>) => void;
onMouseEnter?: (event: MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: MouseEvent<HTMLDivElement>) => 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<HTMLDivElement>) => {
if (!onClick) return;
event.preventDefault();
event.stopPropagation();
onClick?.(event);
};
return (
<StyledHoverableMenuItemBase
data-testid={testId ?? undefined}
onClick={handleMenuItemClick}
className={className}
accent={accent}
isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<StyledMenuItemLeftContent>
{isDefined(avatar) && (
<Avatar
placeholder={avatar.placeholder}
avatarUrl={avatar.avatarUrl}
placeholderColorSeed={avatar.placeholderColorSeed}
size={avatar.size}
type={avatar.type}
/>
)}
<OverflowingTextWithTooltip text={text ?? ''} />
</StyledMenuItemLeftContent>
<div className="hoverable-buttons">
{showIconButtons && (
<LightIconButtonGroup iconButtons={iconButtons} size="small" />
)}
</div>
{hasSubMenu && (
<IconChevronRight
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
)}
</StyledHoverableMenuItemBase>
);
};

View File

@ -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';

View File

@ -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<FeatureFlagEntity>('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,

View File

@ -60,6 +60,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsMessageThreadSubscriberEnabled,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};

View File

@ -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();
};

View File

@ -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 (

View File

@ -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',
}

View File

@ -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';

View File

@ -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,
},
);

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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<MessageThreadWorkspaceEntity>;
@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<WorkspaceMemberWorkspaceEntity>;
@WorkspaceJoinColumn('workspaceMember')
workspaceMemberId: string;
}

View File

@ -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<MessageWorkspaceEntity[]>;
@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<MessageThreadSubscriberWorkspaceEntity[]>;
@WorkspaceRelation({
standardId:
MESSAGE_THREAD_STANDARD_FIELD_IDS.messageChannelMessageAssociations,

View File

@ -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<MessageThreadSubscriberWorkspaceEntity>(
'messageThreadSubscriber',
);
await messageThreadSubscriberRepository.insert({
id,
messageThreadId,
workspaceMemberId,
});
}
public async saveMessageThreadOrReturnExistingMessageThread(
headerMessageId: string,
messageThreadExternalId: string,

View File

@ -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<FavoriteWorkspaceEntity[]>;
@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<MessageThreadSubscriberWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.accountOwnerForCompanies,
type: RelationMetadataType.ONE_TO_MANY,