Improve meeting bot recording tab layout and transcript speakers ui (#22016)
This commit is contained in:
@@ -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],
|
||||
|
||||
+96
@@ -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>
|
||||
);
|
||||
};
|
||||
+55
-56
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+10
-23
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+29
-85
@@ -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;
|
||||
|
||||
+115
@@ -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;
|
||||
+141
@@ -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;
|
||||
};
|
||||
+51
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -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;
|
||||
|
||||
+174
@@ -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);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
|
||||
|
||||
export type CalendarEventParticipantBySpeakerName = Map<
|
||||
string,
|
||||
CalendarEventRecordingParticipant
|
||||
>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type CalendarEventRecordingParticipant = {
|
||||
id: string;
|
||||
avatarUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
nameCandidates: string[];
|
||||
placeholderColorSeed: string;
|
||||
};
|
||||
+22
@@ -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');
|
||||
});
|
||||
});
|
||||
+45
@@ -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;
|
||||
};
|
||||
+48
@@ -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,
|
||||
});
|
||||
};
|
||||
+24
@@ -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;
|
||||
};
|
||||
+64
@@ -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);
|
||||
+3
-5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user