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,
|
canSoftDeleteObjectRecords: false,
|
||||||
canDestroyObjectRecords: false,
|
canDestroyObjectRecords: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
objectUniversalIdentifier:
|
||||||
|
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
|
||||||
|
.universalIdentifier,
|
||||||
|
canReadObjectRecords: true,
|
||||||
|
canUpdateObjectRecords: false,
|
||||||
|
canSoftDeleteObjectRecords: false,
|
||||||
|
canDestroyObjectRecords: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
objectUniversalIdentifier:
|
objectUniversalIdentifier:
|
||||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
|
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
|
||||||
@@ -37,6 +46,23 @@ export default defineApplicationRole({
|
|||||||
canSoftDeleteObjectRecords: false,
|
canSoftDeleteObjectRecords: false,
|
||||||
canDestroyObjectRecords: 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: [],
|
fieldPermissions: [],
|
||||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
|
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 styled from '@emotion/styled';
|
||||||
import { isUndefined } from '@sniptt/guards';
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { RecordingTranscript } from 'src/front-components/components/RecordingTranscript';
|
import { CalendarEventRecordingBody } from 'src/front-components/components/CalendarEventRecordingBody';
|
||||||
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 { 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';
|
import { useCalendarEventRecording } from 'src/front-components/hooks/use-calendar-event-recording';
|
||||||
|
|
||||||
const TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS = 0.25;
|
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;
|
box-sizing: border-box;
|
||||||
font-family: ${recordingThemeCssVariables.font.family};
|
font-family: ${recordingThemeCssVariables.font.family};
|
||||||
height: 100%;
|
|
||||||
padding: ${recordingThemeCssVariables.spacing[4]};
|
padding: ${recordingThemeCssVariables.spacing[4]};
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledCenteredState = styled(StyledStateContainer)`
|
const StyledRecordingHeader = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: ${recordingThemeCssVariables.font.colorTertiary};
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: ${recordingThemeCssVariables.font.sizeSm};
|
height: ${recordingThemeCssVariables.spacing[6]};
|
||||||
justify-content: center;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledRecordingContainer = styled(StyledStateContainer)`
|
const StyledRecordingTitle = styled.h2`
|
||||||
display: flex;
|
color: ${recordingThemeCssVariables.font.colorPrimary};
|
||||||
flex-direction: column;
|
flex: 1;
|
||||||
gap: ${recordingThemeCssVariables.spacing[2]};
|
font-size: ${recordingThemeCssVariables.font.sizeMd};
|
||||||
|
font-weight: ${recordingThemeCssVariables.font.weightMedium};
|
||||||
|
margin: 0;
|
||||||
overflow: hidden;
|
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 = {
|
type CalendarEventRecordingContentProps = {
|
||||||
@@ -63,50 +81,31 @@ export const CalendarEventRecordingContent = ({
|
|||||||
isCalendarEventRecordingQueryLoading,
|
isCalendarEventRecordingQueryLoading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
} = useCalendarEventRecording(calendarEventId);
|
} = useCalendarEventRecording(calendarEventId);
|
||||||
|
const { calendarEventParticipants } =
|
||||||
if (!isUndefined(errorMessage)) {
|
useCalendarEventParticipants(calendarEventId);
|
||||||
return (
|
|
||||||
<TranscriptErrorBox
|
|
||||||
title="Failed to load the recording"
|
|
||||||
description={errorMessage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoFileUrl = videoFile?.url ?? undefined;
|
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 (
|
return (
|
||||||
<StyledRecordingContainer>
|
<StyledRecordingShell>
|
||||||
{hasVideo && (
|
<StyledRecordingHeader>
|
||||||
<RecordingVideoPlayer
|
<StyledRecordingTitle>Recording and Transcript</StyledRecordingTitle>
|
||||||
src={videoFileUrl}
|
</StyledRecordingHeader>
|
||||||
onTimeUpdate={updateCurrentTimeSeconds}
|
<StyledRecordingBody>
|
||||||
/>
|
<StyledRecordingContentFrame>
|
||||||
)}
|
<CalendarEventRecordingBody
|
||||||
<RecordingTranscript
|
transcript={transcript}
|
||||||
transcript={transcript}
|
videoFileUrl={videoFileUrl}
|
||||||
currentTimeSeconds={currentTimeSeconds}
|
isCalendarEventRecordingQueryLoading={
|
||||||
/>
|
isCalendarEventRecordingQueryLoading
|
||||||
</StyledRecordingContainer>
|
}
|
||||||
|
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 { TranscriptEntryList } from 'src/front-components/components/TranscriptEntryList';
|
||||||
import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
|
import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
|
||||||
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
|
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 { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
|
||||||
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
|
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
|
||||||
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
||||||
@@ -16,17 +17,18 @@ const StyledTranscriptCenteredState = styled.div`
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: ${recordingThemeCssVariables.font.sizeSm};
|
font-size: ${recordingThemeCssVariables.font.sizeSm};
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: ${recordingThemeCssVariables.spacing[4]};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type RecordingTranscriptProps = {
|
type RecordingTranscriptProps = {
|
||||||
transcript: unknown;
|
transcript: unknown;
|
||||||
currentTimeSeconds: number;
|
currentTimeSeconds: number;
|
||||||
|
calendarEventParticipants: CalendarEventRecordingParticipant[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecordingTranscript = ({
|
export const RecordingTranscript = ({
|
||||||
transcript,
|
transcript,
|
||||||
currentTimeSeconds,
|
currentTimeSeconds,
|
||||||
|
calendarEventParticipants,
|
||||||
}: RecordingTranscriptProps) => {
|
}: RecordingTranscriptProps) => {
|
||||||
const marker = useMemo(() => parseTranscriptMarker(transcript), [transcript]);
|
const marker = useMemo(() => parseTranscriptMarker(transcript), [transcript]);
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
@@ -84,6 +86,7 @@ export const RecordingTranscript = ({
|
|||||||
<TranscriptEntryList
|
<TranscriptEntryList
|
||||||
entries={entries}
|
entries={entries}
|
||||||
currentTimeSeconds={currentTimeSeconds}
|
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 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`
|
const StyledVideoViewport = styled.div`
|
||||||
aspect-ratio: ${DEFAULT_VIDEO_ASPECT_RATIO};
|
aspect-ratio: ${DEFAULT_VIDEO_ASPECT_RATIO};
|
||||||
background: ${recordingThemeCssVariables.background.primary};
|
background: ${recordingThemeCssVariables.background.primary};
|
||||||
border-radius: ${recordingThemeCssVariables.border.radiusMd};
|
border-radius: ${recordingThemeCssVariables.border.radiusSm};
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
@@ -48,17 +37,15 @@ const RecordingVideoPlayerComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledVideoWrapper>
|
<StyledVideoViewport>
|
||||||
<StyledVideoViewport>
|
<StyledVideo
|
||||||
<StyledVideo
|
controls
|
||||||
controls
|
playsInline
|
||||||
playsInline
|
preload="metadata"
|
||||||
preload="metadata"
|
src={src}
|
||||||
src={src}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
/>
|
||||||
/>
|
</StyledVideoViewport>
|
||||||
</StyledVideoViewport>
|
|
||||||
</StyledVideoWrapper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+29
-85
@@ -1,13 +1,13 @@
|
|||||||
import styled from '@emotion/styled';
|
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 { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
|
||||||
import {
|
import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
|
||||||
type TranscriptEntry,
|
import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
|
||||||
type TranscriptWord,
|
import { buildCalendarEventParticipantBySpeakerName } from 'src/front-components/utils/build-calendar-event-participant-by-speaker-name.util';
|
||||||
} from 'src/front-components/types/transcript-entry.type';
|
|
||||||
import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.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`
|
const StyledTranscriptContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -15,103 +15,47 @@ const StyledTranscriptContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: ${recordingThemeCssVariables.spacing[2]};
|
gap: ${recordingThemeCssVariables.spacing[2]};
|
||||||
min-height: 0;
|
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 = {
|
type TranscriptEntryListProps = {
|
||||||
entries: TranscriptEntry[];
|
entries: TranscriptEntry[];
|
||||||
currentTimeSeconds: number;
|
currentTimeSeconds: number;
|
||||||
|
calendarEventParticipants: CalendarEventRecordingParticipant[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TranscriptEntryList = ({
|
export const TranscriptEntryList = ({
|
||||||
entries,
|
entries,
|
||||||
currentTimeSeconds,
|
currentTimeSeconds,
|
||||||
|
calendarEventParticipants,
|
||||||
}: TranscriptEntryListProps) => {
|
}: TranscriptEntryListProps) => {
|
||||||
const activeEntryIndex = findActiveTranscriptEntryIndex(
|
const activeEntryIndex = findActiveTranscriptEntryIndex(
|
||||||
entries,
|
entries,
|
||||||
currentTimeSeconds,
|
currentTimeSeconds,
|
||||||
);
|
);
|
||||||
|
const calendarEventParticipantBySpeakerName = useMemo(
|
||||||
|
() => buildCalendarEventParticipantBySpeakerName(calendarEventParticipants),
|
||||||
|
[calendarEventParticipants],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledTranscriptContainer>
|
<StyledTranscriptContainer>
|
||||||
{entries.map((entry, entryIndex) => (
|
{entries.map((entry, entryIndex) => {
|
||||||
<StyledEntry
|
const calendarEventParticipant =
|
||||||
key={entryIndex}
|
getCalendarEventParticipantForSpeakerName({
|
||||||
$isActive={entryIndex === activeEntryIndex}
|
speakerName: entry.speakerName,
|
||||||
>
|
calendarEventParticipantBySpeakerName,
|
||||||
<StyledEntryHeader>
|
});
|
||||||
<StyledSpeakerName>{entry.speakerName}</StyledSpeakerName>
|
|
||||||
{!isUndefined(entry.startSeconds) && (
|
return (
|
||||||
<StyledTimestamp>
|
<TranscriptEntryListItem
|
||||||
{formatTranscriptTimestamp(entry.startSeconds)}
|
key={entryIndex}
|
||||||
</StyledTimestamp>
|
entry={entry}
|
||||||
)}
|
isActive={entryIndex === activeEntryIndex}
|
||||||
</StyledEntryHeader>
|
currentTimeSeconds={currentTimeSeconds}
|
||||||
<StyledEntryText>
|
calendarEventParticipant={calendarEventParticipant}
|
||||||
{entry.words.map((word, wordIndex) => (
|
/>
|
||||||
<StyledWord
|
);
|
||||||
key={wordIndex}
|
})}
|
||||||
$isSpoken={isWordSpoken({ word, currentTimeSeconds })}
|
|
||||||
>
|
|
||||||
{wordIndex > 0 ? ' ' : ''}
|
|
||||||
{word.text}
|
|
||||||
</StyledWord>
|
|
||||||
))}
|
|
||||||
</StyledEntryText>
|
|
||||||
</StyledEntry>
|
|
||||||
))}
|
|
||||||
</StyledTranscriptContainer>
|
</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)',
|
colorLight: 'var(--t-border-color-light)',
|
||||||
colorMedium: 'var(--t-border-color-medium)',
|
colorMedium: 'var(--t-border-color-medium)',
|
||||||
radiusMd: 'var(--t-border-radius-md)',
|
radiusMd: 'var(--t-border-radius-md)',
|
||||||
|
radiusSm: 'var(--t-border-radius-sm)',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
light: 'var(--t-box-shadow-light)',
|
light: 'var(--t-box-shadow-light)',
|
||||||
@@ -24,6 +25,7 @@ export const recordingThemeCssVariables = {
|
|||||||
colorSecondary: 'var(--t-font-color-secondary)',
|
colorSecondary: 'var(--t-font-color-secondary)',
|
||||||
colorTertiary: 'var(--t-font-color-tertiary)',
|
colorTertiary: 'var(--t-font-color-tertiary)',
|
||||||
family: 'var(--t-font-family)',
|
family: 'var(--t-font-family)',
|
||||||
|
sizeMd: 'var(--t-font-size-md)',
|
||||||
sizeSm: 'var(--t-font-size-sm)',
|
sizeSm: 'var(--t-font-size-sm)',
|
||||||
sizeXs: 'var(--t-font-size-xs)',
|
sizeXs: 'var(--t-font-size-xs)',
|
||||||
weightMedium: 'var(--t-font-weight-medium)',
|
weightMedium: 'var(--t-font-weight-medium)',
|
||||||
@@ -33,5 +35,6 @@ export const recordingThemeCssVariables = {
|
|||||||
2: 'var(--t-spacing-2)',
|
2: 'var(--t-spacing-2)',
|
||||||
3: 'var(--t-spacing-3)',
|
3: 'var(--t-spacing-3)',
|
||||||
4: 'var(--t-spacing-4)',
|
4: 'var(--t-spacing-4)',
|
||||||
|
6: 'var(--t-spacing-6)',
|
||||||
},
|
},
|
||||||
} as const;
|
} 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_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';
|
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({
|
export default definePageLayoutTab({
|
||||||
universalIdentifier:
|
universalIdentifier:
|
||||||
CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||||
title: 'Recording',
|
title: 'Call Recording',
|
||||||
position: 15,
|
position: 25,
|
||||||
icon: 'IconVideo',
|
icon: 'IconVideo',
|
||||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||||
pageLayoutUniversalIdentifier:
|
pageLayoutUniversalIdentifier:
|
||||||
@@ -22,9 +21,8 @@ export default definePageLayoutTab({
|
|||||||
{
|
{
|
||||||
universalIdentifier:
|
universalIdentifier:
|
||||||
CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||||
title: 'Recording',
|
title: 'Transcript',
|
||||||
type: 'FRONT_COMPONENT',
|
type: 'FRONT_COMPONENT',
|
||||||
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
|
|
||||||
configuration: {
|
configuration: {
|
||||||
configurationType: 'FRONT_COMPONENT',
|
configurationType: 'FRONT_COMPONENT',
|
||||||
frontComponentUniversalIdentifier:
|
frontComponentUniversalIdentifier:
|
||||||
|
|||||||
Reference in New Issue
Block a user