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 (
+
+ {shouldShowAvatarImage ? (
+
+ ) : (
+ getSpeakerInitial(speakerName)
+ )}
+
+ );
+};
+
+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: