Improve meeting bot recording tab layout and transcript speakers ui (#22016)

This commit is contained in:
nitin
2026-06-23 19:56:01 +05:30
committed by GitHub
parent 293ff4c462
commit 99e7c2cde0
19 changed files with 923 additions and 170 deletions
@@ -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],
@@ -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 (
<TranscriptErrorBox
title="Failed to load the recording"
description={errorMessage}
/>
);
}
if (isCalendarEventRecordingQueryLoading) {
return (
<StyledRecordingContainer $hasVideo={false}>
<RecordingVideoPlayer
src={undefined}
onTimeUpdate={onVideoTimeUpdate}
/>
</StyledRecordingContainer>
);
}
if (isUndefined(transcript) && !hasVideo) {
return (
<StyledCenteredState>
No recording for this calendar event yet.
</StyledCenteredState>
);
}
return (
<StyledRecordingContainer $hasVideo={hasVideo}>
{hasVideo && (
<RecordingVideoPlayer
src={videoFileUrl}
onTimeUpdate={onVideoTimeUpdate}
/>
)}
<RecordingTranscript
transcript={transcript}
currentTimeSeconds={currentTimeSeconds}
calendarEventParticipants={calendarEventParticipants}
/>
</StyledRecordingContainer>
);
};
@@ -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 (
<TranscriptErrorBox
title="Failed to load the recording"
description={errorMessage}
/>
);
}
const { calendarEventParticipants } =
useCalendarEventParticipants(calendarEventId);
const videoFileUrl = videoFile?.url ?? undefined;
const hasVideo = !isUndefined(videoFileUrl);
if (isCalendarEventRecordingQueryLoading) {
return (
<StyledRecordingContainer>
<RecordingVideoPlayer
src={undefined}
onTimeUpdate={updateCurrentTimeSeconds}
/>
</StyledRecordingContainer>
);
}
if (isUndefined(transcript) && isUndefined(videoFile)) {
return (
<StyledCenteredState>
No recording for this calendar event yet.
</StyledCenteredState>
);
}
return (
<StyledRecordingContainer>
{hasVideo && (
<RecordingVideoPlayer
src={videoFileUrl}
onTimeUpdate={updateCurrentTimeSeconds}
/>
)}
<RecordingTranscript
transcript={transcript}
currentTimeSeconds={currentTimeSeconds}
/>
</StyledRecordingContainer>
<StyledRecordingShell>
<StyledRecordingHeader>
<StyledRecordingTitle>Recording and Transcript</StyledRecordingTitle>
</StyledRecordingHeader>
<StyledRecordingBody>
<StyledRecordingContentFrame>
<CalendarEventRecordingBody
transcript={transcript}
videoFileUrl={videoFileUrl}
isCalendarEventRecordingQueryLoading={
isCalendarEventRecordingQueryLoading
}
errorMessage={errorMessage}
currentTimeSeconds={currentTimeSeconds}
calendarEventParticipants={calendarEventParticipants}
onVideoTimeUpdate={updateCurrentTimeSeconds}
/>
</StyledRecordingContentFrame>
</StyledRecordingBody>
</StyledRecordingShell>
);
};
@@ -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 = ({
<TranscriptEntryList
entries={entries}
currentTimeSeconds={currentTimeSeconds}
calendarEventParticipants={calendarEventParticipants}
/>
);
};
@@ -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 (
<StyledVideoWrapper>
<StyledVideoViewport>
<StyledVideo
controls
playsInline
preload="metadata"
src={src}
onTimeUpdate={handleTimeUpdate}
/>
</StyledVideoViewport>
</StyledVideoWrapper>
<StyledVideoViewport>
<StyledVideo
controls
playsInline
preload="metadata"
src={src}
onTimeUpdate={handleTimeUpdate}
/>
</StyledVideoViewport>
);
};
@@ -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 (
<StyledTranscriptContainer>
{entries.map((entry, entryIndex) => (
<StyledEntry
key={entryIndex}
$isActive={entryIndex === activeEntryIndex}
>
<StyledEntryHeader>
<StyledSpeakerName>{entry.speakerName}</StyledSpeakerName>
{!isUndefined(entry.startSeconds) && (
<StyledTimestamp>
{formatTranscriptTimestamp(entry.startSeconds)}
</StyledTimestamp>
)}
</StyledEntryHeader>
<StyledEntryText>
{entry.words.map((word, wordIndex) => (
<StyledWord
key={wordIndex}
$isSpoken={isWordSpoken({ word, currentTimeSeconds })}
>
{wordIndex > 0 ? ' ' : ''}
{word.text}
</StyledWord>
))}
</StyledEntryText>
</StyledEntry>
))}
{entries.map((entry, entryIndex) => {
const calendarEventParticipant =
getCalendarEventParticipantForSpeakerName({
speakerName: entry.speakerName,
calendarEventParticipantBySpeakerName,
});
return (
<TranscriptEntryListItem
key={entryIndex}
entry={entry}
isActive={entryIndex === activeEntryIndex}
currentTimeSeconds={currentTimeSeconds}
calendarEventParticipant={calendarEventParticipant}
/>
);
})}
</StyledTranscriptContainer>
);
};
const isWordSpoken = ({
word,
currentTimeSeconds,
}: {
word: TranscriptWord;
currentTimeSeconds: number;
}): boolean =>
!isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds;
@@ -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 (
<StyledEntry $isActive={isActive}>
<StyledEntryHeader>
<TranscriptSpeakerChip
speakerName={speakerDisplayName}
avatarUrl={calendarEventParticipant?.avatarUrl}
placeholderColorSeed={
calendarEventParticipant?.placeholderColorSeed ?? speakerDisplayName
}
/>
{!isUndefined(entry.startSeconds) && (
<StyledTimestamp>
{formatTranscriptTimestamp(entry.startSeconds)}
</StyledTimestamp>
)}
</StyledEntryHeader>
<StyledEntryText>
{entry.words.map((word, wordIndex) => (
<StyledWord
key={wordIndex}
$isSpoken={isWordSpoken({ word, currentTimeSeconds })}
>
{wordIndex > 0 ? ' ' : ''}
{word.text}
</StyledWord>
))}
</StyledEntryText>
</StyledEntry>
);
};
const isWordSpoken = ({
word,
currentTimeSeconds,
}: {
word: TranscriptWord;
currentTimeSeconds: number;
}): boolean =>
!isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds;
@@ -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 (
<StyledAvatar
aria-hidden="true"
$backgroundColor={avatarPlaceholderBackgroundColor}
$color={avatarPlaceholderColor}
>
{shouldShowAvatarImage ? (
<StyledAvatarImage
src={avatarUrl}
alt=""
onError={handleAvatarImageError}
/>
) : (
getSpeakerInitial(speakerName)
)}
</StyledAvatar>
);
};
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;
};
@@ -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 (
<StyledSpeakerChip>
<TranscriptSpeakerAvatar
speakerName={speakerName}
avatarUrl={avatarUrl}
placeholderColorSeed={placeholderColorSeed}
/>
<StyledSpeakerName>{speakerName}</StyledSpeakerName>
</StyledSpeakerChip>
);
};
@@ -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;
@@ -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);
@@ -0,0 +1,6 @@
import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
export type CalendarEventParticipantBySpeakerName = Map<
string,
CalendarEventRecordingParticipant
>;
@@ -0,0 +1,7 @@
export type CalendarEventRecordingParticipant = {
id: string;
avatarUrl: string | undefined;
displayName: string | undefined;
nameCandidates: string[];
placeholderColorSeed: string;
};
@@ -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');
});
});
@@ -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<string>();
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;
};
@@ -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,
});
};
@@ -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;
};
@@ -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);
@@ -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: