From 99e7c2cde0c59329b45be18fd32c00a1353ea970 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:56:01 +0530 Subject: [PATCH] Improve meeting bot recording tab layout and transcript speakers ui (#22016) --- .../twenty-meeting-bot/src/default-role.ts | 26 +++ .../components/CalendarEventRecordingBody.tsx | 96 ++++++++++ .../CalendarEventRecordingContent.tsx | 111 ++++++----- .../components/RecordingTranscript.tsx | 5 +- .../components/RecordingVideoPlayer.tsx | 33 +--- .../components/TranscriptEntryList.tsx | 114 +++--------- .../components/TranscriptEntryListItem.tsx | 115 ++++++++++++ .../components/TranscriptSpeakerAvatar.tsx | 141 ++++++++++++++ .../components/TranscriptSpeakerChip.tsx | 51 +++++ .../recording-theme-css-variables.ts | 3 + .../hooks/use-calendar-event-participants.ts | 174 ++++++++++++++++++ ...-event-participant-by-speaker-name.type.ts | 6 + ...lendar-event-recording-participant.type.ts | 7 + .../get-speaker-name-match-keys.test.ts | 22 +++ ...-event-participant-by-speaker-name.util.ts | 45 +++++ .../utils/get-absolute-avatar-url.util.ts | 48 +++++ ...event-participant-for-speaker-name.util.ts | 24 +++ .../utils/get-speaker-name-match-keys.util.ts | 64 +++++++ .../calendar-event-recording-tab.ts | 8 +- 19 files changed, 923 insertions(+), 170 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts index ff071e51bf..62df301574 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts @@ -29,6 +29,15 @@ export default defineApplicationRole({ canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant + .universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier, @@ -37,6 +46,23 @@ export default defineApplicationRole({ canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember + .universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, ], fieldPermissions: [], permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE], diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx new file mode 100644 index 0000000000..7847f43409 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx @@ -0,0 +1,96 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; + +import { RecordingTranscript } from 'src/front-components/components/RecordingTranscript'; +import { RecordingVideoPlayer } from 'src/front-components/components/RecordingVideoPlayer'; +import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; + +const StyledCenteredState = styled.div` + align-items: center; + box-sizing: border-box; + color: ${recordingThemeCssVariables.font.colorTertiary}; + display: flex; + font-family: ${recordingThemeCssVariables.font.family}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + justify-content: center; + min-height: 240px; + padding: ${recordingThemeCssVariables.spacing[4]}; +`; + +const StyledRecordingContainer = styled.div<{ + $hasVideo?: boolean; +}>` + display: grid; + gap: ${recordingThemeCssVariables.spacing[2]}; + grid-template-rows: ${({ $hasVideo }) => + $hasVideo ? 'auto minmax(0, 1fr)' : 'minmax(0, 1fr)'}; + min-height: 0; +`; + +type CalendarEventRecordingBodyProps = { + transcript: unknown; + videoFileUrl: string | undefined; + isCalendarEventRecordingQueryLoading: boolean; + errorMessage: string | undefined; + currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; + onVideoTimeUpdate: (videoCurrentTimeSeconds: number) => void; +}; + +export const CalendarEventRecordingBody = ({ + transcript, + videoFileUrl, + isCalendarEventRecordingQueryLoading, + errorMessage, + currentTimeSeconds, + calendarEventParticipants, + onVideoTimeUpdate, +}: CalendarEventRecordingBodyProps) => { + const hasVideo = !isUndefined(videoFileUrl); + + if (!isUndefined(errorMessage)) { + return ( + + ); + } + + if (isCalendarEventRecordingQueryLoading) { + return ( + + + + ); + } + + if (isUndefined(transcript) && !hasVideo) { + return ( + + No recording for this calendar event yet. + + ); + } + + return ( + + {hasVideo && ( + + )} + + + ); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx index 5b65a87f5f..3b1e5f07ec 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx @@ -1,36 +1,54 @@ import styled from '@emotion/styled'; -import { isUndefined } from '@sniptt/guards'; import { useCallback, useState } from 'react'; -import { RecordingTranscript } from 'src/front-components/components/RecordingTranscript'; -import { RecordingVideoPlayer } from 'src/front-components/components/RecordingVideoPlayer'; -import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox'; +import { CalendarEventRecordingBody } from 'src/front-components/components/CalendarEventRecordingBody'; import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { useCalendarEventParticipants } from 'src/front-components/hooks/use-calendar-event-participants'; import { useCalendarEventRecording } from 'src/front-components/hooks/use-calendar-event-recording'; const TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS = 0.25; -const StyledStateContainer = styled.div` +const StyledRecordingShell = styled.div` + background: ${recordingThemeCssVariables.background.primary}; + border: 1px solid transparent; + border-bottom: 1px solid transparent; + border-radius: ${recordingThemeCssVariables.border.radiusMd}; box-sizing: border-box; font-family: ${recordingThemeCssVariables.font.family}; - height: 100%; padding: ${recordingThemeCssVariables.spacing[4]}; + position: relative; + width: 100%; `; -const StyledCenteredState = styled(StyledStateContainer)` +const StyledRecordingHeader = styled.div` align-items: center; - color: ${recordingThemeCssVariables.font.colorTertiary}; + box-sizing: border-box; display: flex; - font-size: ${recordingThemeCssVariables.font.sizeSm}; - justify-content: center; + height: ${recordingThemeCssVariables.spacing[6]}; `; -const StyledRecordingContainer = styled(StyledStateContainer)` - display: flex; - flex-direction: column; - gap: ${recordingThemeCssVariables.spacing[2]}; +const StyledRecordingTitle = styled.h2` + color: ${recordingThemeCssVariables.font.colorPrimary}; + flex: 1; + font-size: ${recordingThemeCssVariables.font.sizeMd}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + margin: 0; overflow: hidden; - padding: 0; + padding-inline: ${recordingThemeCssVariables.spacing[1]}; + user-select: none; +`; + +const StyledRecordingBody = styled.div` + box-sizing: border-box; + margin-top: ${recordingThemeCssVariables.spacing[2]}; +`; + +const StyledRecordingContentFrame = styled.div` + background-color: ${recordingThemeCssVariables.background.secondary}; + border: 1px solid ${recordingThemeCssVariables.border.colorMedium}; + border-radius: ${recordingThemeCssVariables.border.radiusMd}; + box-sizing: border-box; + padding: ${recordingThemeCssVariables.spacing[2]}; `; type CalendarEventRecordingContentProps = { @@ -63,50 +81,31 @@ export const CalendarEventRecordingContent = ({ isCalendarEventRecordingQueryLoading, errorMessage, } = useCalendarEventRecording(calendarEventId); - - if (!isUndefined(errorMessage)) { - return ( - - ); - } + const { calendarEventParticipants } = + useCalendarEventParticipants(calendarEventId); const videoFileUrl = videoFile?.url ?? undefined; - const hasVideo = !isUndefined(videoFileUrl); - - if (isCalendarEventRecordingQueryLoading) { - return ( - - - - ); - } - - if (isUndefined(transcript) && isUndefined(videoFile)) { - return ( - - No recording for this calendar event yet. - - ); - } return ( - - {hasVideo && ( - - )} - - + + + Recording and Transcript + + + + + + + ); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx index b75eb46ec5..5666e2f5e0 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react'; import { TranscriptEntryList } from 'src/front-components/components/TranscriptEntryList'; import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox'; import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util'; import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; @@ -16,17 +17,18 @@ const StyledTranscriptCenteredState = styled.div` flex: 1; font-size: ${recordingThemeCssVariables.font.sizeSm}; justify-content: center; - padding: ${recordingThemeCssVariables.spacing[4]}; `; type RecordingTranscriptProps = { transcript: unknown; currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; }; export const RecordingTranscript = ({ transcript, currentTimeSeconds, + calendarEventParticipants, }: RecordingTranscriptProps) => { const marker = useMemo(() => parseTranscriptMarker(transcript), [transcript]); const entries = useMemo( @@ -84,6 +86,7 @@ export const RecordingTranscript = ({ ); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx index 1b054a99c0..40a60ab04a 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx @@ -5,21 +5,10 @@ import { recordingThemeCssVariables } from 'src/front-components/constants/recor const DEFAULT_VIDEO_ASPECT_RATIO = '16 / 9'; -const StyledVideoWrapper = styled.div` - background: ${recordingThemeCssVariables.background.primary}; - border-radius: ${recordingThemeCssVariables.border.radiusMd}; - box-sizing: border-box; - flex-shrink: 0; - margin-inline: auto; - overflow: hidden; - padding: ${recordingThemeCssVariables.spacing[2]}; - width: 100%; -`; - const StyledVideoViewport = styled.div` aspect-ratio: ${DEFAULT_VIDEO_ASPECT_RATIO}; background: ${recordingThemeCssVariables.background.primary}; - border-radius: ${recordingThemeCssVariables.border.radiusMd}; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; overflow: hidden; width: 100%; `; @@ -48,17 +37,15 @@ const RecordingVideoPlayerComponent = ({ }; return ( - - - - - + + + ); }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx index fc46197348..43f4b7a479 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx @@ -1,13 +1,13 @@ import styled from '@emotion/styled'; -import { isUndefined } from '@sniptt/guards'; +import { useMemo } from 'react'; +import { TranscriptEntryListItem } from 'src/front-components/components/TranscriptEntryListItem'; import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; -import { - type TranscriptEntry, - type TranscriptWord, -} from 'src/front-components/types/transcript-entry.type'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type'; +import { buildCalendarEventParticipantBySpeakerName } from 'src/front-components/utils/build-calendar-event-participant-by-speaker-name.util'; import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.util'; -import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util'; +import { getCalendarEventParticipantForSpeakerName } from 'src/front-components/utils/get-calendar-event-participant-for-speaker-name.util'; const StyledTranscriptContainer = styled.div` display: flex; @@ -15,103 +15,47 @@ const StyledTranscriptContainer = styled.div` flex-direction: column; gap: ${recordingThemeCssVariables.spacing[2]}; min-height: 0; - overflow-y: auto; -`; - -const StyledEntry = styled.div<{ $isActive: boolean }>` - background: ${({ $isActive }) => - $isActive - ? recordingThemeCssVariables.background.transparentBlue - : 'transparent'}; - border-radius: ${recordingThemeCssVariables.border.radiusMd}; - display: flex; - flex-direction: column; - gap: ${recordingThemeCssVariables.spacing[1]}; - padding: ${recordingThemeCssVariables.spacing[3]}; -`; - -const StyledEntryHeader = styled.div` - align-items: baseline; - display: flex; - gap: ${recordingThemeCssVariables.spacing[2]}; -`; - -const StyledSpeakerName = styled.span` - color: ${recordingThemeCssVariables.font.colorPrimary}; - font-size: ${recordingThemeCssVariables.font.sizeSm}; - font-weight: ${recordingThemeCssVariables.font.weightMedium}; -`; - -const StyledTimestamp = styled.span` - color: ${recordingThemeCssVariables.font.colorTertiary}; - font-size: ${recordingThemeCssVariables.font.sizeXs}; -`; - -const StyledEntryText = styled.p` - line-height: 1.5; - margin: 0; -`; - -const StyledWord = styled.span<{ $isSpoken: boolean }>` - color: ${({ $isSpoken }) => - $isSpoken - ? recordingThemeCssVariables.font.colorPrimary - : recordingThemeCssVariables.font.colorTertiary}; - font-size: ${recordingThemeCssVariables.font.sizeSm}; - transition: color 0.15s ease; `; type TranscriptEntryListProps = { entries: TranscriptEntry[]; currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; }; export const TranscriptEntryList = ({ entries, currentTimeSeconds, + calendarEventParticipants, }: TranscriptEntryListProps) => { const activeEntryIndex = findActiveTranscriptEntryIndex( entries, currentTimeSeconds, ); + const calendarEventParticipantBySpeakerName = useMemo( + () => buildCalendarEventParticipantBySpeakerName(calendarEventParticipants), + [calendarEventParticipants], + ); return ( - {entries.map((entry, entryIndex) => ( - - - {entry.speakerName} - {!isUndefined(entry.startSeconds) && ( - - {formatTranscriptTimestamp(entry.startSeconds)} - - )} - - - {entry.words.map((word, wordIndex) => ( - - {wordIndex > 0 ? ' ' : ''} - {word.text} - - ))} - - - ))} + {entries.map((entry, entryIndex) => { + const calendarEventParticipant = + getCalendarEventParticipantForSpeakerName({ + speakerName: entry.speakerName, + calendarEventParticipantBySpeakerName, + }); + + return ( + + ); + })} ); }; - -const isWordSpoken = ({ - word, - currentTimeSeconds, -}: { - word: TranscriptWord; - currentTimeSeconds: number; -}): boolean => - !isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx new file mode 100644 index 0000000000..044489c48b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx @@ -0,0 +1,115 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; + +import { TranscriptSpeakerChip } from 'src/front-components/components/TranscriptSpeakerChip'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { + type TranscriptEntry, + type TranscriptWord, +} from 'src/front-components/types/transcript-entry.type'; +import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util'; + +const StyledEntry = styled.div<{ $isActive: boolean }>` + align-items: flex-start; + background: ${({ $isActive }) => + $isActive + ? recordingThemeCssVariables.background.transparentBlue + : 'transparent'}; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${recordingThemeCssVariables.spacing[2]}; + justify-content: center; + padding: ${recordingThemeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledEntryHeader = styled.div` + align-items: center; + align-self: stretch; + display: flex; + gap: ${recordingThemeCssVariables.spacing[2]}; + min-height: ${recordingThemeCssVariables.spacing[6]}; + min-width: 0; +`; + +const StyledTimestamp = styled.span` + color: ${recordingThemeCssVariables.font.colorTertiary}; + font-size: ${recordingThemeCssVariables.font.sizeXs}; + line-height: 1.4; +`; + +const StyledEntryText = styled.p` + align-self: stretch; + color: ${recordingThemeCssVariables.font.colorSecondary}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + line-height: 1.4; + margin: 0; +`; + +const StyledWord = styled.span<{ $isSpoken: boolean }>` + color: ${({ $isSpoken }) => + $isSpoken + ? recordingThemeCssVariables.font.colorPrimary + : recordingThemeCssVariables.font.colorSecondary}; + line-height: 1.4; + transition: color 0.15s ease; +`; + +type TranscriptEntryListItemProps = { + entry: TranscriptEntry; + isActive: boolean; + currentTimeSeconds: number; + calendarEventParticipant: CalendarEventRecordingParticipant | undefined; +}; + +export const TranscriptEntryListItem = ({ + entry, + isActive, + currentTimeSeconds, + calendarEventParticipant, +}: TranscriptEntryListItemProps) => { + const speakerDisplayName = + calendarEventParticipant?.displayName ?? entry.speakerName; + + return ( + + + + {!isUndefined(entry.startSeconds) && ( + + {formatTranscriptTimestamp(entry.startSeconds)} + + )} + + + {entry.words.map((word, wordIndex) => ( + + {wordIndex > 0 ? ' ' : ''} + {word.text} + + ))} + + + ); +}; + +const isWordSpoken = ({ + word, + currentTimeSeconds, +}: { + word: TranscriptWord; + currentTimeSeconds: number; +}): boolean => + !isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx new file mode 100644 index 0000000000..c2695ab2c3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx @@ -0,0 +1,141 @@ +// Duplicates minimal twenty-ui Avatar logic for this app. +// Remove once twenty-ui can be imported safely in front components. +import styled from '@emotion/styled'; +import { useState } from 'react'; + +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const AVATAR_COLOR_NAMES = [ + 'red', + 'ruby', + 'crimson', + 'tomato', + 'orange', + 'amber', + 'yellow', + 'lime', + 'grass', + 'green', + 'jade', + 'mint', + 'turquoise', + 'cyan', + 'sky', + 'blue', + 'iris', + 'violet', + 'purple', + 'plum', + 'pink', + 'bronze', + 'gold', + 'brown', + 'gray', +] as const; + +const StyledAvatar = styled.div<{ + $backgroundColor: string; + $color: string; +}>` + align-items: center; + background: ${({ $backgroundColor }) => $backgroundColor}; + border-radius: 50px; + box-sizing: border-box; + color: ${({ $color }) => $color}; + display: flex; + flex-shrink: 0; + font-size: ${recordingThemeCssVariables.font.sizeXs}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + height: 16px; + justify-content: center; + line-height: 15px; + overflow: hidden; + width: 16px; +`; + +const StyledAvatarImage = styled.img` + height: 100%; + object-fit: cover; + width: 100%; +`; + +type TranscriptSpeakerAvatarProps = { + speakerName: string; + avatarUrl: string | undefined; + placeholderColorSeed: string; +}; + +const getSpeakerInitial = (speakerName: string) => + speakerName.trim().charAt(0).toUpperCase() || '-'; + +export const TranscriptSpeakerAvatar = ({ + speakerName, + avatarUrl, + placeholderColorSeed, +}: TranscriptSpeakerAvatarProps) => { + const [erroredAvatarUrl, setErroredAvatarUrl] = useState< + string | undefined + >(undefined); + + const shouldShowAvatarImage = + isNonEmptyString(avatarUrl) && erroredAvatarUrl !== avatarUrl; + + const handleAvatarImageError = () => { + if (isNonEmptyString(avatarUrl)) { + setErroredAvatarUrl(avatarUrl); + } + }; + + const avatarPlaceholderColor = getAvatarPlaceholderColor({ + placeholderColorSeed, + variant: 12, + }); + const avatarPlaceholderBackgroundColor = getAvatarPlaceholderColor({ + placeholderColorSeed, + variant: 4, + }); + + return ( + + ); +}; + +const getAvatarPlaceholderColor = ({ + placeholderColorSeed, + variant, +}: { + placeholderColorSeed: string; + variant: 4 | 12; +}): string => { + const avatarColorName = + AVATAR_COLOR_NAMES[ + Math.abs(hashString(placeholderColorSeed)) % AVATAR_COLOR_NAMES.length + ]; + + return `var(--t-color-${avatarColorName}${variant})`; +}; + +const hashString = (value: string): number => { + let hash = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex++) { + hash = value.charCodeAt(valueIndex) + ((hash << 5) - hash); + } + + return hash; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx new file mode 100644 index 0000000000..d7863eb42a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx @@ -0,0 +1,51 @@ +// Duplicates minimal twenty-ui Chip logic for this app. +// Remove once twenty-ui can be imported safely in front components. +import styled from '@emotion/styled'; + +import { TranscriptSpeakerAvatar } from 'src/front-components/components/TranscriptSpeakerAvatar'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; + +const StyledSpeakerChip = styled.span` + align-items: center; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; + color: ${recordingThemeCssVariables.font.colorPrimary}; + display: inline-flex; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + gap: ${recordingThemeCssVariables.spacing[1]}; + line-height: 1.4; + max-width: 100%; + min-width: 0; + text-decoration: none; + white-space: nowrap; +`; + +const StyledSpeakerName = styled.span` + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +`; + +type TranscriptSpeakerChipProps = { + speakerName: string; + avatarUrl: string | undefined; + placeholderColorSeed: string; +}; + +export const TranscriptSpeakerChip = ({ + speakerName, + avatarUrl, + placeholderColorSeed, +}: TranscriptSpeakerChipProps) => { + return ( + + + {speakerName} + + ); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts index fdeed48776..a8cce2347f 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts @@ -14,6 +14,7 @@ export const recordingThemeCssVariables = { colorLight: 'var(--t-border-color-light)', colorMedium: 'var(--t-border-color-medium)', radiusMd: 'var(--t-border-radius-md)', + radiusSm: 'var(--t-border-radius-sm)', }, boxShadow: { light: 'var(--t-box-shadow-light)', @@ -24,6 +25,7 @@ export const recordingThemeCssVariables = { colorSecondary: 'var(--t-font-color-secondary)', colorTertiary: 'var(--t-font-color-tertiary)', family: 'var(--t-font-family)', + sizeMd: 'var(--t-font-size-md)', sizeSm: 'var(--t-font-size-sm)', sizeXs: 'var(--t-font-size-xs)', weightMedium: 'var(--t-font-weight-medium)', @@ -33,5 +35,6 @@ export const recordingThemeCssVariables = { 2: 'var(--t-spacing-2)', 3: 'var(--t-spacing-3)', 4: 'var(--t-spacing-4)', + 6: 'var(--t-spacing-6)', }, } as const; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts new file mode 100644 index 0000000000..a1eb0ef14a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getAbsoluteAvatarUrl } from 'src/front-components/utils/get-absolute-avatar-url.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT = 100; + +type CalendarEventParticipantName = { + firstName?: string | null; + lastName?: string | null; +}; + +type CalendarEventParticipantRelatedRecord = { + id?: string | null; + avatarUrl?: string | null; + name?: CalendarEventParticipantName | null; +}; + +type CalendarEventParticipantNode = { + id: string; + displayName?: string | null; + handle?: string | null; + personId?: string | null; + workspaceMemberId?: string | null; + person?: CalendarEventParticipantRelatedRecord | null; + workspaceMember?: CalendarEventParticipantRelatedRecord | null; +}; + +type CalendarEventParticipantEdge = { + node: CalendarEventParticipantNode; +}; + +type UseCalendarEventParticipantsReturn = { + calendarEventParticipants: CalendarEventRecordingParticipant[]; +}; + +export const useCalendarEventParticipants = ( + calendarEventId: string | undefined, +): UseCalendarEventParticipantsReturn => { + const [calendarEventParticipants, setCalendarEventParticipants] = useState< + CalendarEventRecordingParticipant[] + >([]); + + useEffect(() => { + if (!isNonEmptyString(calendarEventId)) { + setCalendarEventParticipants([]); + return; + } + + let cancelled = false; + + const fetchCalendarEventParticipants = async () => { + try { + const client = new CoreApiClient(); + const queryResult = await client.query({ + calendarEventParticipants: { + __args: { + filter: { calendarEventId: { eq: calendarEventId } }, + first: CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT, + }, + edges: { + node: { + id: true, + displayName: true, + handle: true, + personId: true, + workspaceMemberId: true, + person: { + id: true, + avatarUrl: true, + name: { + firstName: true, + lastName: true, + }, + }, + workspaceMember: { + id: true, + avatarUrl: true, + name: { + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (cancelled) { + return; + } + + const calendarEventParticipantEdges = (queryResult + .calendarEventParticipants?.edges ?? + []) as CalendarEventParticipantEdge[]; + + setCalendarEventParticipants( + calendarEventParticipantEdges.map((calendarEventParticipantEdge) => + mapCalendarEventParticipantNode( + calendarEventParticipantEdge.node, + ), + ), + ); + } catch { + if (cancelled) { + return; + } + + setCalendarEventParticipants([]); + } + }; + + fetchCalendarEventParticipants(); + + return () => { + cancelled = true; + }; + }, [calendarEventId]); + + return { calendarEventParticipants }; +}; + +const mapCalendarEventParticipantNode = ( + calendarEventParticipantNode: CalendarEventParticipantNode, +): CalendarEventRecordingParticipant => { + const personName = readFullName(calendarEventParticipantNode.person?.name); + const workspaceMemberName = readFullName( + calendarEventParticipantNode.workspaceMember?.name, + ); + const calendarDisplayName = readOptionalString( + calendarEventParticipantNode.displayName, + ); + const handle = readOptionalString(calendarEventParticipantNode.handle); + + return { + id: calendarEventParticipantNode.id, + avatarUrl: getAbsoluteAvatarUrl( + calendarEventParticipantNode.person?.avatarUrl ?? + calendarEventParticipantNode.workspaceMember?.avatarUrl, + ), + displayName: + personName ?? workspaceMemberName ?? calendarDisplayName ?? handle, + nameCandidates: [ + calendarDisplayName, + personName, + workspaceMemberName, + handle, + ].filter((nameCandidate): nameCandidate is string => + isNonEmptyString(nameCandidate), + ), + placeholderColorSeed: + calendarEventParticipantNode.workspaceMemberId ?? + calendarEventParticipantNode.personId ?? + calendarEventParticipantNode.id, + }; +}; + +const readFullName = ( + name: CalendarEventParticipantName | null | undefined, +): string | undefined => { + const firstName = readOptionalString(name?.firstName); + const lastName = readOptionalString(name?.lastName); + const fullName = [firstName, lastName] + .filter((namePart): namePart is string => isNonEmptyString(namePart)) + .join(' '); + + return isNonEmptyString(fullName) ? fullName : undefined; +}; + +const readOptionalString = ( + value: string | null | undefined, +): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts new file mode 100644 index 0000000000..c6fbe5e000 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts @@ -0,0 +1,6 @@ +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; + +export type CalendarEventParticipantBySpeakerName = Map< + string, + CalendarEventRecordingParticipant +>; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts new file mode 100644 index 0000000000..ec760f1bb6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts @@ -0,0 +1,7 @@ +export type CalendarEventRecordingParticipant = { + id: string; + avatarUrl: string | undefined; + displayName: string | undefined; + nameCandidates: string[]; + placeholderColorSeed: string; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts new file mode 100644 index 0000000000..32711416bd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +describe('getSpeakerNameMatchKeys', () => { + it('matches transcript full names to compact calendar aliases', () => { + expect(getSpeakerNameMatchKeys('Martin Muller')).toContain('martmull'); + expect(getSpeakerNameMatchKeys('Martmull92')).toContain('martmull'); + }); + + it('keeps exact normalized full names available for regular participant names', () => { + expect(getSpeakerNameMatchKeys('Nitin Koche')).toEqual([ + 'nitin koche', + 'nitinkoche', + 'nitikoch', + ]); + }); + + it('folds accents before generating compact match keys', () => { + expect(getSpeakerNameMatchKeys('Martin Müller')).toContain('martmull'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts new file mode 100644 index 0000000000..cb70604e1a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts @@ -0,0 +1,45 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +export const buildCalendarEventParticipantBySpeakerName = ( + calendarEventParticipants: CalendarEventRecordingParticipant[], +): CalendarEventParticipantBySpeakerName => { + const calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName = + new Map(); + const ambiguousSpeakerNameMatchKeys = new Set(); + + for (const calendarEventParticipant of calendarEventParticipants) { + for (const nameCandidate of calendarEventParticipant.nameCandidates) { + const speakerNameMatchKeys = getSpeakerNameMatchKeys(nameCandidate); + + for (const speakerNameMatchKey of speakerNameMatchKeys) { + const matchingCalendarEventParticipant = + calendarEventParticipantBySpeakerName.get(speakerNameMatchKey); + + if (ambiguousSpeakerNameMatchKeys.has(speakerNameMatchKey)) { + continue; + } + + if (isUndefined(matchingCalendarEventParticipant)) { + calendarEventParticipantBySpeakerName.set( + speakerNameMatchKey, + calendarEventParticipant, + ); + continue; + } + + if ( + matchingCalendarEventParticipant.id !== calendarEventParticipant.id + ) { + calendarEventParticipantBySpeakerName.delete(speakerNameMatchKey); + ambiguousSpeakerNameMatchKeys.add(speakerNameMatchKey); + } + } + } + } + + return calendarEventParticipantBySpeakerName; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts new file mode 100644 index 0000000000..404717f70c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts @@ -0,0 +1,48 @@ +// Duplicates minimal front image URL logic for this app. +// Remove once shared front utilities can be imported safely in front components. +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type GetImageAbsoluteUrlArgs = { + imageUrl: string; + baseUrl: string; +}; + +const getImageAbsoluteUrl = ({ + imageUrl, + baseUrl, +}: GetImageAbsoluteUrlArgs): string => { + const lowerCaseImageUrl = imageUrl.toLowerCase(); + const isAlreadyAbsoluteUrl = + ['http:', 'https:', 'data:', 'blob:'].some((scheme) => + lowerCaseImageUrl.startsWith(scheme), + ) || imageUrl.startsWith('//'); + + if (isAlreadyAbsoluteUrl) { + return imageUrl; + } + + if (imageUrl.startsWith('/')) { + return new URL(`/files${imageUrl}`, baseUrl).toString(); + } + + return new URL(`/files/${imageUrl}`, baseUrl).toString(); +}; + +export const getAbsoluteAvatarUrl = ( + avatarUrl: string | null | undefined, +): string | undefined => { + if (!isNonEmptyString(avatarUrl)) { + return undefined; + } + + const apiBaseUrl = process.env.TWENTY_API_URL; + + if (!isNonEmptyString(apiBaseUrl)) { + return avatarUrl.trim(); + } + + return getImageAbsoluteUrl({ + imageUrl: avatarUrl.trim(), + baseUrl: apiBaseUrl, + }); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts new file mode 100644 index 0000000000..261f4dc79b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts @@ -0,0 +1,24 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +export const getCalendarEventParticipantForSpeakerName = ({ + speakerName, + calendarEventParticipantBySpeakerName, +}: { + speakerName: string; + calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName; +}): CalendarEventRecordingParticipant | undefined => { + for (const speakerNameMatchKey of getSpeakerNameMatchKeys(speakerName)) { + const calendarEventParticipant = + calendarEventParticipantBySpeakerName.get(speakerNameMatchKey); + + if (!isUndefined(calendarEventParticipant)) { + return calendarEventParticipant; + } + } + + return undefined; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts new file mode 100644 index 0000000000..c570effd71 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts @@ -0,0 +1,64 @@ +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const MINIMUM_FUZZY_MATCH_KEY_LENGTH = 5; + +export const getSpeakerNameMatchKeys = (speakerName: string): string[] => { + const normalizedSpeakerName = normalizeSpeakerName(speakerName); + const compactSpeakerName = getCompactSpeakerName(normalizedSpeakerName); + const compactSpeakerNameWithoutDigits = compactSpeakerName.replace(/\d/g, ''); + const abbreviatedSpeakerNameMatchKey = + getAbbreviatedSpeakerNameMatchKey(normalizedSpeakerName); + + return [ + ...new Set( + [ + normalizedSpeakerName, + compactSpeakerName, + compactSpeakerNameWithoutDigits, + abbreviatedSpeakerNameMatchKey, + ].filter(isSpeakerNameMatchKey), + ), + ]; +}; + +const normalizeSpeakerName = (speakerName: string): string => + speakerName + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase(); + +const getCompactSpeakerName = (speakerName: string): string => + normalizeSpeakerName(speakerName).replace(/[^a-z0-9]/g, ''); + +const getAbbreviatedSpeakerNameMatchKey = ( + speakerName: string, +): string | undefined => { + const speakerNameParts = normalizeSpeakerName(speakerName) + .split(/\s+/) + .map(getCompactSpeakerName) + .filter(isNonEmptyString); + + if (speakerNameParts.length < 2) { + return undefined; + } + + const firstSpeakerNamePart = speakerNameParts[0]; + const lastSpeakerNamePart = speakerNameParts[speakerNameParts.length - 1]; + const abbreviatedSpeakerNameMatchKey = `${firstSpeakerNamePart.slice( + 0, + 4, + )}${lastSpeakerNamePart.slice(0, 4)}`; + + return abbreviatedSpeakerNameMatchKey.length >= + MINIMUM_FUZZY_MATCH_KEY_LENGTH + ? abbreviatedSpeakerNameMatchKey + : undefined; +}; + +const isSpeakerNameMatchKey = ( + speakerNameMatchKey: string | undefined, +): speakerNameMatchKey is string => + isNonEmptyString(speakerNameMatchKey) && + (speakerNameMatchKey.includes(' ') || + speakerNameMatchKey.length >= MINIMUM_FUZZY_MATCH_KEY_LENGTH); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts index 1690b3f4dc..90d63b7142 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts @@ -8,12 +8,11 @@ import { CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 's import { CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-page-layout-tab-universal-identifier'; import { CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-page-layout-widget-universal-identifier'; -// Position 15 slots the tab between the standard Home (10) and Timeline (20) tabs. export default definePageLayoutTab({ universalIdentifier: CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, - title: 'Recording', - position: 15, + title: 'Call Recording', + position: 25, icon: 'IconVideo', layoutMode: PageLayoutTabLayoutMode.CANVAS, pageLayoutUniversalIdentifier: @@ -22,9 +21,8 @@ export default definePageLayoutTab({ { universalIdentifier: CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, - title: 'Recording', + title: 'Transcript', type: 'FRONT_COMPONENT', - gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 }, configuration: { configurationType: 'FRONT_COMPONENT', frontComponentUniversalIdentifier: