feat(call-recorder): add copy-to-clipboard buttons for transcript, summary, and video link (#23052)
## Context Closes twentyhq/core-team-issues#2692. Adds copy-to-clipboard actions to the call recorder app so users can quickly share a call's transcript, summary, and video. ## What changed - **Copy transcript** button in the *Recording and Transcript* widget header. Copies the transcript as plain text with resolved speaker display names and timestamps (mirroring what is shown on screen). - **Copy video download link** button in the same header. Copies the signed video file URL. - **Copy summary** button in the *Summary* widget header. Copies the summary markdown. Each button is powered by a new reusable `CopyToClipboardButton` component that writes to the clipboard, briefly swaps to a check icon for feedback, and surfaces a success/error snackbar. Buttons are disabled when there is nothing to copy (no transcript / video / summary, or while loading). A `buildTranscriptPlainText` utility turns parsed transcript entries into shareable text, with participant display names preferred over raw diarized speaker labels. ## Screenshots The *Recording and Transcript* header now shows a copy-transcript and a copy-video-link button, and the *Summary* header shows a copy-summary button. | Light | Dark | | --- | --- | | <img width="426" src="https://raw.githubusercontent.com/twentyhq/twenty/claude/issue-2692-screenshots/.github/pr-screenshots/2692/call-recorder-copy-buttons-light.png" /> | <img width="426" src="https://raw.githubusercontent.com/twentyhq/twenty/claude/issue-2692-screenshots/.github/pr-screenshots/2692/call-recorder-copy-buttons-dark.png" /> | ## Tests - New unit tests for `buildTranscriptPlainText` (speaker/timestamp formatting, missing timestamps, participant name resolution). - Full app unit suite passes (491 tests), plus typecheck and lint.
This commit is contained in:
+33
-1
@@ -1,10 +1,15 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IconLink } from 'twenty-ui/icon';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventRecordingBody } from 'src/front-components/components/CalendarEventRecordingBody';
|
||||
import { CopyToClipboardButton } from 'src/front-components/components/CopyToClipboardButton';
|
||||
import { useCalendarEventParticipants } from 'src/front-components/hooks/use-calendar-event-participants';
|
||||
import { useCalendarEventRecording } from 'src/front-components/hooks/use-calendar-event-recording';
|
||||
import { buildTranscriptPlainText } from 'src/front-components/utils/build-transcript-plain-text.util';
|
||||
import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
|
||||
|
||||
const TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS = 0.25;
|
||||
|
||||
@@ -38,6 +43,12 @@ const StyledRecordingTitle = styled.h2`
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledRecordingHeaderActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${() => themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledRecordingBody = styled.div`
|
||||
box-sizing: border-box;
|
||||
margin-top: ${() => themeCssVariables.spacing[2]};
|
||||
@@ -90,10 +101,31 @@ export const CalendarEventRecordingContent = ({
|
||||
|
||||
const videoFileUrl = videoFile?.url ?? undefined;
|
||||
|
||||
const transcriptPlainText = useMemo(() => {
|
||||
const entries = parseTranscriptEntries(transcript);
|
||||
|
||||
if (!isNonEmptyArray(entries)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildTranscriptPlainText({ entries, calendarEventParticipants });
|
||||
}, [transcript, calendarEventParticipants]);
|
||||
|
||||
return (
|
||||
<StyledRecordingShell>
|
||||
<StyledRecordingHeader>
|
||||
<StyledRecordingTitle>Recording and Transcript</StyledRecordingTitle>
|
||||
<StyledRecordingHeaderActions>
|
||||
<CopyToClipboardButton
|
||||
textToCopy={transcriptPlainText}
|
||||
ariaLabel="Copy transcript"
|
||||
/>
|
||||
<CopyToClipboardButton
|
||||
textToCopy={videoFileUrl}
|
||||
ariaLabel="Copy video download link"
|
||||
Icon={IconLink}
|
||||
/>
|
||||
</StyledRecordingHeaderActions>
|
||||
</StyledRecordingHeader>
|
||||
<StyledRecordingBody>
|
||||
<StyledRecordingContentFrame>
|
||||
|
||||
+5
@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventSummaryBody } from 'src/front-components/components/CalendarEventSummaryBody';
|
||||
import { CopyToClipboardButton } from 'src/front-components/components/CopyToClipboardButton';
|
||||
import { useCalendarEventSummary } from 'src/front-components/hooks/use-calendar-event-summary';
|
||||
|
||||
const StyledSummaryShell = styled.div`
|
||||
@@ -60,6 +61,10 @@ export const CalendarEventSummaryContent = ({
|
||||
<StyledSummaryShell>
|
||||
<StyledSummaryHeader>
|
||||
<StyledSummaryTitle>Summary</StyledSummaryTitle>
|
||||
<CopyToClipboardButton
|
||||
textToCopy={summaryMarkdown}
|
||||
ariaLabel="Copy summary"
|
||||
/>
|
||||
</StyledSummaryHeader>
|
||||
<StyledSummaryBody>
|
||||
<StyledSummaryContentFrame>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { copyToClipboard } from 'twenty-sdk/front-component';
|
||||
import { IconCopy, type IconComponent } from 'twenty-ui/icon';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const COPY_ICON_SIZE = 16;
|
||||
|
||||
const StyledButton = styled.button`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${() => themeCssVariables.border.color.medium};
|
||||
border-radius: ${() => themeCssVariables.border.radius.sm};
|
||||
color: ${() => themeCssVariables.font.color.secondary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: ${() => themeCssVariables.spacing[6]};
|
||||
justify-content: center;
|
||||
padding: 0 ${() => themeCssVariables.spacing[1]};
|
||||
transition: background 0.1s ease;
|
||||
width: ${() => themeCssVariables.spacing[6]};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${() => themeCssVariables.background.tertiary};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${() => themeCssVariables.font.color.light};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
type CopyToClipboardButtonProps = {
|
||||
textToCopy: string | undefined;
|
||||
ariaLabel: string;
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
export const CopyToClipboardButton = ({
|
||||
textToCopy,
|
||||
ariaLabel,
|
||||
Icon = IconCopy,
|
||||
}: CopyToClipboardButtonProps) => {
|
||||
const isDisabled = textToCopy === undefined || textToCopy.length === 0;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The host performs the clipboard write and owns the success/error
|
||||
// snackbar; the front-component sandbox has no direct navigator.clipboard
|
||||
// access, and the host does not report back whether the copy succeeded.
|
||||
void copyToClipboard(textToCopy);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
disabled={isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon size={COPY_ICON_SIZE} />
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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 { buildTranscriptPlainText } from 'src/front-components/utils/build-transcript-plain-text.util';
|
||||
|
||||
const buildEntry = (
|
||||
overrides: Partial<TranscriptEntry> & Pick<TranscriptEntry, 'speakerName'>,
|
||||
): TranscriptEntry => ({
|
||||
startSeconds: undefined,
|
||||
endSeconds: undefined,
|
||||
text: '',
|
||||
words: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildTranscriptPlainText', () => {
|
||||
it('renders each entry with speaker, timestamp, and text separated by blank lines', () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
buildEntry({
|
||||
speakerName: 'Ada Lovelace',
|
||||
startSeconds: 72,
|
||||
text: 'Hello there',
|
||||
}),
|
||||
buildEntry({
|
||||
speakerName: 'Grace Hopper',
|
||||
startSeconds: 130,
|
||||
text: 'Hi',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
buildTranscriptPlainText({ entries, calendarEventParticipants: [] }),
|
||||
).toBe('Ada Lovelace (1:12)\nHello there\n\nGrace Hopper (2:10)\nHi');
|
||||
});
|
||||
|
||||
it('omits the timestamp when the entry has no start time', () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
buildEntry({ speakerName: 'Ada Lovelace', text: 'Hello there' }),
|
||||
];
|
||||
|
||||
expect(
|
||||
buildTranscriptPlainText({ entries, calendarEventParticipants: [] }),
|
||||
).toBe('Ada Lovelace\nHello there');
|
||||
});
|
||||
|
||||
it('prefers the matched participant display name over the raw speaker label', () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
buildEntry({
|
||||
speakerName: 'ada lovelace',
|
||||
startSeconds: 0,
|
||||
text: 'Hello there',
|
||||
}),
|
||||
];
|
||||
const calendarEventParticipants: CalendarEventRecordingParticipant[] = [
|
||||
{
|
||||
id: 'participant-1',
|
||||
avatarUrl: undefined,
|
||||
displayName: 'Ada L.',
|
||||
nameCandidates: ['Ada Lovelace'],
|
||||
placeholderColorSeed: 'participant-1',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
buildTranscriptPlainText({ entries, calendarEventParticipants }),
|
||||
).toBe('Ada L. (0:00)\nHello there');
|
||||
});
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
|
||||
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 { getCalendarEventParticipantForSpeakerName } from 'src/front-components/utils/get-calendar-event-participant-for-speaker-name.util';
|
||||
import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
|
||||
|
||||
// Mirrors the on-screen transcript: participant display name resolution wins
|
||||
// over the raw diarized speaker label so the copied text matches what is read.
|
||||
export const buildTranscriptPlainText = ({
|
||||
entries,
|
||||
calendarEventParticipants,
|
||||
}: {
|
||||
entries: TranscriptEntry[];
|
||||
calendarEventParticipants: CalendarEventRecordingParticipant[];
|
||||
}): string => {
|
||||
const calendarEventParticipantBySpeakerName =
|
||||
buildCalendarEventParticipantBySpeakerName(calendarEventParticipants);
|
||||
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const calendarEventParticipant =
|
||||
getCalendarEventParticipantForSpeakerName({
|
||||
speakerName: entry.speakerName,
|
||||
calendarEventParticipantBySpeakerName,
|
||||
});
|
||||
const speakerDisplayName =
|
||||
calendarEventParticipant?.displayName ?? entry.speakerName;
|
||||
const timestamp = isUndefined(entry.startSeconds)
|
||||
? ''
|
||||
: ` (${formatSecondsAsClockTimestamp(entry.startSeconds)})`;
|
||||
|
||||
return `${speakerDisplayName}${timestamp}\n${entry.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
};
|
||||
Reference in New Issue
Block a user