From 1b5e974629e9ad4914e44ac80b54b7df3182fdb0 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 20 Jul 2026 17:40:17 +0200 Subject: [PATCH] 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 | | --- | --- | | | | ## 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. --- .../CalendarEventRecordingContent.tsx | 34 ++++++++- .../CalendarEventSummaryContent.tsx | 5 ++ .../components/CopyToClipboardButton.tsx | 67 ++++++++++++++++++ .../build-transcript-plain-text.test.ts | 69 +++++++++++++++++++ .../utils/build-transcript-plain-text.util.ts | 37 ++++++++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CopyToClipboardButton.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/build-transcript-plain-text.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/build-transcript-plain-text.util.ts diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventRecordingContent.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventRecordingContent.tsx index ae05ac5ff1..389123e512 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventRecordingContent.tsx +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventRecordingContent.tsx @@ -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 ( Recording and Transcript + + + + diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx index 6656ff3067..a6d8877f10 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx @@ -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 = ({ Summary + diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CopyToClipboardButton.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CopyToClipboardButton.tsx new file mode 100644 index 0000000000..c10d2c1343 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CopyToClipboardButton.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/build-transcript-plain-text.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/build-transcript-plain-text.test.ts new file mode 100644 index 0000000000..e4482dfd4e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/build-transcript-plain-text.test.ts @@ -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 & Pick, +): 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'); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/build-transcript-plain-text.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/build-transcript-plain-text.util.ts new file mode 100644 index 0000000000..5d9c0060f5 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/build-transcript-plain-text.util.ts @@ -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'); +};