Show call recorders as calendar event participants (#23380)

Closes twentyhq/core-team-issues#2729

Call recordings attached to a calendar event are now displayed next to
the human participants, in the timeline event card
(`EventCardCalendarEvent`).

They are rendered as the source app's `AppChip`, rounded so it sits in
the participant avatar group, with the recording status in tooltip



https://github.com/user-attachments/assets/e0c393c8-fd65-4468-8c4f-503dd22c13d5

## Before
No call recorder chip displayed 

TODO: add this in the calendar views (`CalendarEventRow`)
This commit is contained in:
martmull
2026-07-28 23:26:59 +02:00
committed by GitHub
parent f84f242f9d
commit 00e418a039
20 changed files with 308 additions and 29 deletions
@@ -0,0 +1,74 @@
import { AppChip } from '@/applications/components/AppChip';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useId } from 'react';
import { createPortal } from 'react-dom';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces';
import { CallRecordingStatus } from '~/generated/graphql';
const IN_PROGRESS_CALL_RECORDING_STATUSES: CallRecordingStatus[] = [
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
];
const getCallRecordingStatusLabel = (status: CallRecordingStatus) => {
switch (status) {
case CallRecordingStatus.SCHEDULED:
return t`Scheduled`;
case CallRecordingStatus.JOINING:
return t`Joining`;
case CallRecordingStatus.RECORDING:
return t`Recording`;
case CallRecordingStatus.PROCESSING:
return t`Processing`;
case CallRecordingStatus.COMPLETED:
return t`Completed`;
case CallRecordingStatus.FAILED:
return t`Failed`;
}
};
const StyledContainer = styled.div<{ isDisabled: boolean }>`
display: flex;
filter: ${({ isDisabled }) => (isDisabled ? 'grayscale(1)' : 'none')};
`;
type CalendarEventCallRecorderAvatarProps = {
applicationId?: string | null;
status: CallRecordingStatus;
};
export const CalendarEventCallRecorderAvatar = ({
applicationId,
status,
}: CalendarEventCallRecorderAvatarProps) => {
const instanceId = useId();
const hasFailed = status === CallRecordingStatus.FAILED;
const tooltipAnchorId = `call-recorder-${instanceId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
return (
<>
<StyledContainer id={tooltipAnchorId} isDisabled={hasFailed}>
<AppChip
applicationId={applicationId}
fallbackApplicationData={{ name: t`Call recorder` }}
pulsing={IN_PROGRESS_CALL_RECORDING_STATUSES.includes(status)}
size="md"
rounded
chipOnly
/>
</StyledContainer>
{createPortal(
<AppTooltip
anchorSelect={`#${tooltipAnchorId}`}
content={getCallRecordingStatusLabel(status)}
delay={TooltipDelay.shortDelay}
place={TooltipPosition.Top}
positionStrategy="fixed"
/>,
document.body,
)}
</>
);
};
@@ -1,3 +1,5 @@
import { CalendarEventCallRecorderAvatar } from '@/activities/calendar/components/CalendarEventCallRecorderAvatar';
import { type CalendarEventCallRecording } from '@/activities/calendar/types/CalendarEventCallRecording';
import { type CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { isTimelineCalendarEventParticipant } from '@/activities/calendar/types/guards/IsTimelineCalendarEventParticipant';
import { isDefined } from 'twenty-shared/utils';
@@ -7,10 +9,12 @@ import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type CalendarEventParticipantsAvatarGroupProps = {
participants: CalendarEventParticipant[] | TimelineCalendarEventParticipant[];
callRecordings?: CalendarEventCallRecording[];
};
export const CalendarEventParticipantsAvatarGroup = ({
participants,
callRecordings = [],
}: CalendarEventParticipantsAvatarGroupProps) => {
const timelineParticipants: TimelineCalendarEventParticipant[] =
participants.map((participant) => {
@@ -47,23 +51,32 @@ export const CalendarEventParticipantsAvatarGroup = ({
return (
<AvatarGroup
avatars={timelineParticipants.map((participant) => (
<Avatar
key={[participant.workspaceMemberId, participant.displayName]
.filter(isDefined)
.join('-')}
avatarUrl={getAbsoluteImageUrl(participant.avatarUrl)}
placeholder={
participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: participant.displayName
}
placeholderColorSeed={
participant.workspaceMemberId || participant.personId || ''
}
type="rounded"
/>
))}
avatars={[
...callRecordings.map((callRecording) => (
<CalendarEventCallRecorderAvatar
key={callRecording.id}
applicationId={callRecording.applicationId}
status={callRecording.status}
/>
)),
...timelineParticipants.map((participant) => (
<Avatar
key={[participant.workspaceMemberId, participant.displayName]
.filter(isDefined)
.join('-')}
avatarUrl={getAbsoluteImageUrl(participant.avatarUrl)}
placeholder={
participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: participant.displayName
}
placeholderColorSeed={
participant.workspaceMemberId || participant.personId || ''
}
type="rounded"
/>
)),
]}
/>
);
};
@@ -125,9 +125,11 @@ export const CalendarEventRow = ({
<CalendarEventNotSharedContent />
)}
</StyledLabels>
{!!calendarEvent.participants?.length && (
{(!!calendarEvent.participants?.length ||
!!calendarEvent.callRecordings?.length) && (
<CalendarEventParticipantsAvatarGroup
participants={calendarEvent.participants}
participants={calendarEvent.participants ?? []}
callRecordings={calendarEvent.callRecordings}
/>
)}
</StyledContainer>
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const timelineCalendarEventCallRecordingFragment = gql`
fragment TimelineCalendarEventCallRecordingFragment on TimelineCalendarEventCallRecording {
id
status
applicationId
}
`;
@@ -1,3 +1,4 @@
import { timelineCalendarEventCallRecordingFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventCallRecordingFragment';
import { timelineCalendarEventParticipantFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventParticipantFragment';
import { gql } from '@apollo/client';
@@ -14,6 +15,10 @@ export const timelineCalendarEventFragment = gql`
participants {
...TimelineCalendarEventParticipantFragment
}
callRecordings {
...TimelineCalendarEventCallRecordingFragment
}
}
${timelineCalendarEventParticipantFragment}
${timelineCalendarEventCallRecordingFragment}
`;
@@ -23,6 +23,7 @@ const calendarEvents: TimelineCalendarEvent[] = [
isCanceled: false,
location: 'Location',
participants: [],
callRecordings: [],
title: 'Title',
__typename: 'TimelineCalendarEvent',
},
@@ -42,6 +43,7 @@ const calendarEvents: TimelineCalendarEvent[] = [
isCanceled: false,
location: 'Location',
participants: [],
callRecordings: [],
title: 'Title',
__typename: 'TimelineCalendarEvent',
},
@@ -61,6 +63,7 @@ const calendarEvents: TimelineCalendarEvent[] = [
isCanceled: false,
location: 'Location',
participants: [],
callRecordings: [],
title: 'Title',
__typename: 'TimelineCalendarEvent',
},
@@ -80,6 +83,7 @@ const calendarEvents: TimelineCalendarEvent[] = [
isCanceled: false,
location: 'Location',
participants: [],
callRecordings: [],
title: 'Title',
__typename: 'TimelineCalendarEvent',
},
@@ -1,3 +1,4 @@
import { type CalendarEventCallRecording } from '@/activities/calendar/types/CalendarEventCallRecording';
import { type CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { type CalendarChannelVisibility } from '~/generated/graphql';
@@ -18,5 +19,6 @@ export type CalendarEvent = {
title?: string;
visibility: CalendarChannelVisibility;
calendarEventParticipants?: CalendarEventParticipant[];
callRecordings?: CalendarEventCallRecording[];
__typename: 'CalendarEvent';
};
@@ -0,0 +1,7 @@
import { type CallRecordingStatus } from '~/generated/graphql';
export type CalendarEventCallRecording = {
id: string;
status: CallRecordingStatus;
applicationId?: string | null;
};
@@ -116,6 +116,11 @@ export const EventCardCalendarEvent = ({
handle: true,
displayName: true,
},
callRecordings: {
id: true,
status: true,
applicationId: true,
},
},
});
@@ -195,9 +200,11 @@ export const EventCardCalendarEvent = ({
{calendarEvent.title}
</StyledCalendarEventTitle>
)}
{!!calendarEvent.calendarEventParticipants?.length && (
{(!!calendarEvent.calendarEventParticipants?.length ||
!!calendarEvent.callRecordings?.length) && (
<CalendarEventParticipantsAvatarGroup
participants={calendarEvent.calendarEventParticipants}
participants={calendarEvent.calendarEventParticipants ?? []}
callRecordings={calendarEvent.callRecordings}
/>
)}
</StyledCalendarEventTop>
@@ -17,6 +17,8 @@ type AppChipProps = {
};
className?: string;
chipOnly?: boolean;
rounded?: boolean;
pulsing?: boolean;
};
const StyledContainer = styled.div`
@@ -38,6 +40,8 @@ export const AppChip = ({
fallbackApplicationData,
className,
chipOnly = false,
rounded = false,
pulsing = false,
}: AppChipProps) => {
const { applicationChipData } = useApplicationChipData({
applicationId,
@@ -47,7 +51,7 @@ export const AppChip = ({
return (
<StyledContainer className={className}>
<Avatar
type="app"
type={rounded ? 'rounded' : 'app'}
size={size}
avatarUrl={getAbsoluteImageUrl(logoUrl ?? applicationChipData.logo)}
placeholder={applicationChipData.name}
@@ -55,6 +59,7 @@ export const AppChip = ({
color={applicationChipData.colors?.color}
backgroundColor={applicationChipData.colors?.backgroundColor}
borderColor={applicationChipData.colors?.borderColor}
pulsing={pulsing}
/>
{!chipOnly && (
<OverflowingTextWithTooltip text={applicationChipData.name} />
@@ -49,6 +49,7 @@ export const SettingsAccountsCalendarChannelsGeneral = () => {
workspaceMemberId: currentWorkspaceMember?.id || '',
},
],
callRecordings: [],
endsAt: exampleEndDate.toISOString(),
isFullDay: false,
startsAt: exampleStartDate.toISOString(),