@twentyhq/call-recorder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/.env.example +5 -0
  2. package/.nvmrc +1 -0
  3. package/.oxlintrc.json +20 -0
  4. package/.yarnrc.yml +1 -0
  5. package/AGENTS.md +67 -0
  6. package/CLAUDE.md +67 -0
  7. package/README.md +24 -0
  8. package/SETUP.md +95 -0
  9. package/package.json +42 -0
  10. package/public/gallery/call-recorder-cover.png +0 -0
  11. package/public/logo.svg +5 -0
  12. package/src/__tests__/global-setup.ts +100 -0
  13. package/src/__tests__/schema.integration-test.ts +104 -0
  14. package/src/application-config.ts +96 -0
  15. package/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts +19 -0
  16. package/src/constants/app-description.ts +2 -0
  17. package/src/constants/app-display-name.ts +1 -0
  18. package/src/constants/application-universal-identifier.ts +2 -0
  19. package/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts +2 -0
  20. package/src/constants/calendar-event-record-page-layout-universal-identifier.ts +2 -0
  21. package/src/constants/calendar-event-recording-front-component-universal-identifier.ts +2 -0
  22. package/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts +2 -0
  23. package/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts +2 -0
  24. package/src/constants/call-recorder-everyone-left-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  25. package/src/constants/call-recorder-failure-reason-on-call-recording-field-universal-identifier.ts +2 -0
  26. package/src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier.ts +2 -0
  27. package/src/constants/call-recorder-name-app-variable-universal-identifier.ts +2 -0
  28. package/src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  29. package/src/constants/call-recorder-preference-off-option-id.ts +2 -0
  30. package/src/constants/call-recorder-preference-on-calendar-event-field-universal-identifier.ts +2 -0
  31. package/src/constants/call-recorder-preference-on-calendar-event-view-field-universal-identifier.ts +2 -0
  32. package/src/constants/call-recorder-preference-on-option-id.ts +2 -0
  33. package/src/constants/call-recorder-preference.ts +4 -0
  34. package/src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  35. package/src/constants/call-recording-audio-field-universal-identifier.ts +2 -0
  36. package/src/constants/call-recording-video-field-universal-identifier.ts +2 -0
  37. package/src/constants/default-role-universal-identifier.ts +2 -0
  38. package/src/constants/process-recall-webhook-logic-function-universal-identifier.ts +2 -0
  39. package/src/constants/recall-webhook-logic-function-universal-identifier.ts +2 -0
  40. package/src/constants/stale-bot-state-logic-function-universal-identifier.ts +2 -0
  41. package/src/default-role.ts +69 -0
  42. package/src/fields/call-recorder-failure-reason-on-call-recording.field.ts +22 -0
  43. package/src/fields/call-recorder-preference-on-calendar-event.field.ts +41 -0
  44. package/src/front-components/calendar-event-recording.front-component.tsx +13 -0
  45. package/src/front-components/components/CalendarEventRecording.tsx +39 -0
  46. package/src/front-components/components/CalendarEventRecordingBody.tsx +96 -0
  47. package/src/front-components/components/CalendarEventRecordingContent.tsx +111 -0
  48. package/src/front-components/components/RecordingTranscript.tsx +92 -0
  49. package/src/front-components/components/RecordingVideoPlayer.tsx +52 -0
  50. package/src/front-components/components/TranscriptEntryList.tsx +61 -0
  51. package/src/front-components/components/TranscriptEntryListItem.tsx +115 -0
  52. package/src/front-components/components/TranscriptErrorBox.tsx +48 -0
  53. package/src/front-components/components/TranscriptSpeakerAvatar.tsx +141 -0
  54. package/src/front-components/components/TranscriptSpeakerChip.tsx +51 -0
  55. package/src/front-components/constants/recording-theme-css-variables.ts +40 -0
  56. package/src/front-components/hooks/use-calendar-event-participants.ts +172 -0
  57. package/src/front-components/hooks/use-calendar-event-recording.ts +155 -0
  58. package/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts +6 -0
  59. package/src/front-components/types/calendar-event-recording-participant.type.ts +7 -0
  60. package/src/front-components/types/transcript-entry.type.ts +13 -0
  61. package/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts +66 -0
  62. package/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts +29 -0
  63. package/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts +22 -0
  64. package/src/front-components/utils/__tests__/parse-transcript-entries.test.ts +162 -0
  65. package/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts +45 -0
  66. package/src/front-components/utils/find-active-transcript-entry-index.util.ts +77 -0
  67. package/src/front-components/utils/format-transcript-timestamp.util.ts +16 -0
  68. package/src/front-components/utils/get-absolute-avatar-url.util.ts +48 -0
  69. package/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts +24 -0
  70. package/src/front-components/utils/get-speaker-name-match-keys.util.ts +64 -0
  71. package/src/front-components/utils/get-video-file-extension.util.ts +23 -0
  72. package/src/front-components/utils/parse-transcript-entries.util.ts +85 -0
  73. package/src/logic-functions/__tests__/process-recall-webhook.test.ts +62 -0
  74. package/src/logic-functions/__tests__/recall-webhook.test.ts +180 -0
  75. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name.ts +2 -0
  76. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds.ts +1 -0
  77. package/src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name.ts +2 -0
  78. package/src/logic-functions/constants/call-recorder-name-env-var-name.ts +1 -0
  79. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name.ts +2 -0
  80. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds.ts +1 -0
  81. package/src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name.ts +2 -0
  82. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name.ts +2 -0
  83. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds.ts +1 -0
  84. package/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts +1 -0
  85. package/src/logic-functions/constants/call-recording-request-status.ts +5 -0
  86. package/src/logic-functions/constants/call-recording-status.ts +9 -0
  87. package/src/logic-functions/constants/default-call-recorder-join-early-minutes.ts +1 -0
  88. package/src/logic-functions/constants/default-call-recorder-name.ts +1 -0
  89. package/src/logic-functions/constants/default-call-recorder-recording-retention-hours.ts +2 -0
  90. package/src/logic-functions/constants/default-recall-region.ts +1 -0
  91. package/src/logic-functions/constants/milliseconds-per-minute.ts +1 -0
  92. package/src/logic-functions/constants/non-terminal-call-recording-statuses.ts +8 -0
  93. package/src/logic-functions/constants/recall-api-key-env-var-name.ts +1 -0
  94. package/src/logic-functions/constants/recall-api-max-attempts.ts +1 -0
  95. package/src/logic-functions/constants/recall-api-retry-delay-ms.ts +1 -0
  96. package/src/logic-functions/constants/recall-bot-automatic-leave.ts +74 -0
  97. package/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts +1 -0
  98. package/src/logic-functions/constants/recall-bot-recording-config.ts +34 -0
  99. package/src/logic-functions/constants/recall-region-env-var-name.ts +1 -0
  100. package/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts +1 -0
  101. package/src/logic-functions/constants/restricted-field-placeholder.ts +3 -0
  102. package/src/logic-functions/constants/stale-bot-state-cron-pattern.ts +1 -0
  103. package/src/logic-functions/constants/twenty-page-size.ts +1 -0
  104. package/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts +55 -0
  105. package/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts +43 -0
  106. package/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts +38 -0
  107. package/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts +22 -0
  108. package/src/logic-functions/data/complete-call-recording-ingestion.util.ts +24 -0
  109. package/src/logic-functions/data/create-call-recording.util.ts +41 -0
  110. package/src/logic-functions/data/fetch-all-nodes.util.ts +44 -0
  111. package/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts +80 -0
  112. package/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts +20 -0
  113. package/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts +19 -0
  114. package/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts +17 -0
  115. package/src/logic-functions/data/find-call-recordings-by-filter.util.ts +102 -0
  116. package/src/logic-functions/data/find-call-recordings-by-ids.util.ts +17 -0
  117. package/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts +14 -0
  118. package/src/logic-functions/data/get-current-workspace-id.util.ts +36 -0
  119. package/src/logic-functions/data/strip-restricted-field-value.util.ts +6 -0
  120. package/src/logic-functions/data/update-call-recording.util.ts +24 -0
  121. package/src/logic-functions/domain/__tests__/build-call-recorder-policy-result.test.ts +47 -0
  122. package/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts +71 -0
  123. package/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts +37 -0
  124. package/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts +88 -0
  125. package/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts +59 -0
  126. package/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts +37 -0
  127. package/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts +120 -0
  128. package/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts +102 -0
  129. package/src/logic-functions/domain/aggregate-call-recorder-policy-results-by-meeting.util.ts +42 -0
  130. package/src/logic-functions/domain/build-call-recorder-policy-result.util.ts +53 -0
  131. package/src/logic-functions/domain/build-failed-transcript-marker.util.ts +13 -0
  132. package/src/logic-functions/domain/build-pending-transcript-marker.util.ts +13 -0
  133. package/src/logic-functions/domain/build-recall-routing-metadata.util.ts +12 -0
  134. package/src/logic-functions/domain/build-transcript-failure-reason.util.ts +7 -0
  135. package/src/logic-functions/domain/compute-call-recording-charge.util.ts +41 -0
  136. package/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts +16 -0
  137. package/src/logic-functions/domain/compute-real-meeting-key.util.ts +48 -0
  138. package/src/logic-functions/domain/compute-recall-bot-join-at.util.ts +34 -0
  139. package/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts +19 -0
  140. package/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts +37 -0
  141. package/src/logic-functions/domain/is-recall-recording-done-signal.util.ts +13 -0
  142. package/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts +26 -0
  143. package/src/logic-functions/domain/parse-transcript-marker.util.ts +29 -0
  144. package/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts +72 -0
  145. package/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts +32 -0
  146. package/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts +45 -0
  147. package/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts +61 -0
  148. package/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +727 -0
  149. package/src/logic-functions/flows/__tests__/download-transcript.test.ts +74 -0
  150. package/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +1301 -0
  151. package/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts +225 -0
  152. package/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts +153 -0
  153. package/src/logic-functions/flows/__tests__/reap-orphaned-call-recorders.test.ts +425 -0
  154. package/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +1007 -0
  155. package/src/logic-functions/flows/cancel-call-recording-request.util.ts +46 -0
  156. package/src/logic-functions/flows/charge-completed-call-recording.util.ts +31 -0
  157. package/src/logic-functions/flows/complete-and-charge-call-recording.util.ts +29 -0
  158. package/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts +8 -0
  159. package/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +447 -0
  160. package/src/logic-functions/flows/download-transcript.util.ts +67 -0
  161. package/src/logic-functions/flows/ensure-call-recorder.util.ts +73 -0
  162. package/src/logic-functions/flows/handle-recall-webhook.util.ts +672 -0
  163. package/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts +82 -0
  164. package/src/logic-functions/flows/ingest-call-recording-media.util.ts +128 -0
  165. package/src/logic-functions/flows/persist-call-recording-progress.util.ts +58 -0
  166. package/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts +183 -0
  167. package/src/logic-functions/flows/reconcile-call-recorder.util.ts +495 -0
  168. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts +11 -0
  169. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts +182 -0
  170. package/src/logic-functions/flows/reschedule-call-recording-bot.util.ts +69 -0
  171. package/src/logic-functions/process-recall-webhook.ts +23 -0
  172. package/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts +153 -0
  173. package/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts +67 -0
  174. package/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts +744 -0
  175. package/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts +122 -0
  176. package/src/logic-functions/recall-api/cancel-recall-bot.util.ts +28 -0
  177. package/src/logic-functions/recall-api/create-async-recall-transcript.util.ts +47 -0
  178. package/src/logic-functions/recall-api/eject-recall-bot.util.ts +28 -0
  179. package/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts +149 -0
  180. package/src/logic-functions/recall-api/extract-recall-bot-id.util.ts +10 -0
  181. package/src/logic-functions/recall-api/extract-recall-media-urls.util.ts +30 -0
  182. package/src/logic-functions/recall-api/extract-twenty-workspace-id-from-recall-webhook.util.ts +8 -0
  183. package/src/logic-functions/recall-api/get-recall-api-config.util.ts +59 -0
  184. package/src/logic-functions/recall-api/get-recall-bot.util.ts +42 -0
  185. package/src/logic-functions/recall-api/get-recall-recording.util.ts +31 -0
  186. package/src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util.ts +18 -0
  187. package/src/logic-functions/recall-api/list-recall-transcripts.util.ts +141 -0
  188. package/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts +106 -0
  189. package/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts +14 -0
  190. package/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts +88 -0
  191. package/src/logic-functions/recall-api/recall-bot-api-request.util.ts +165 -0
  192. package/src/logic-functions/recall-api/recall-transcript-summary.type.ts +5 -0
  193. package/src/logic-functions/recall-api/reschedule-recall-bot.util.ts +56 -0
  194. package/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts +71 -0
  195. package/src/logic-functions/recall-api/schedule-recall-bot.util.ts +68 -0
  196. package/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts +109 -0
  197. package/src/logic-functions/recall-webhook.ts +90 -0
  198. package/src/logic-functions/reconcile-call-recorder-calendar-event.ts +178 -0
  199. package/src/logic-functions/reconcile-stale-bot-state.ts +106 -0
  200. package/src/logic-functions/types/calendar-event-record.type.ts +5 -0
  201. package/src/logic-functions/types/call-recorder-policy-calendar-event-input.type.ts +10 -0
  202. package/src/logic-functions/types/call-recorder-policy-input.type.ts +9 -0
  203. package/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts +5 -0
  204. package/src/logic-functions/types/call-recorder-policy-required-reason.type.ts +1 -0
  205. package/src/logic-functions/types/call-recorder-policy-result-for-calendar-event.type.ts +9 -0
  206. package/src/logic-functions/types/call-recorder-policy-result-for-meeting.type.ts +6 -0
  207. package/src/logic-functions/types/call-recorder-policy-result.type.ts +12 -0
  208. package/src/logic-functions/types/call-recorder-reconciliation-result.type.ts +16 -0
  209. package/src/logic-functions/types/call-recording-media-file.type.ts +1 -0
  210. package/src/logic-functions/types/call-recording-record.type.ts +15 -0
  211. package/src/logic-functions/types/call-recording-update-fields.type.ts +20 -0
  212. package/src/logic-functions/types/files-field-value.type.ts +1 -0
  213. package/src/logic-functions/types/meeting-recording.type.ts +7 -0
  214. package/src/logic-functions/types/recall-bot-operation-result.type.ts +19 -0
  215. package/src/logic-functions/types/recall-routing-metadata.type.ts +4 -0
  216. package/src/logic-functions/types/removed-call-recorder-occurrence.type.ts +6 -0
  217. package/src/logic-functions/types/transcript-marker.type.ts +6 -0
  218. package/src/logic-functions/utils/as-record.util.ts +6 -0
  219. package/src/logic-functions/utils/get-application-variable-value.util.ts +3 -0
  220. package/src/logic-functions/utils/get-record-at-path.util.ts +10 -0
  221. package/src/logic-functions/utils/get-string.util.ts +4 -0
  222. package/src/logic-functions/utils/get-unique-sorted-ids.util.ts +8 -0
  223. package/src/logic-functions/utils/is-non-empty-string.util.ts +5 -0
  224. package/src/page-layouts/calendar-event-recording-tab.ts +33 -0
  225. package/src/view-fields/call-recorder-preference-on-calendar-event.view-field.ts +27 -0
  226. package/tsconfig.json +42 -0
  227. package/tsconfig.spec.json +9 -0
  228. package/vitest.config.ts +31 -0
  229. package/vitest.unit.config.ts +14 -0
@@ -0,0 +1,52 @@
1
+ import styled from '@emotion/styled';
2
+ import { memo, type SyntheticEvent } from 'react';
3
+
4
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
5
+
6
+ const DEFAULT_VIDEO_ASPECT_RATIO = '16 / 9';
7
+
8
+ const StyledVideoViewport = styled.div`
9
+ aspect-ratio: ${DEFAULT_VIDEO_ASPECT_RATIO};
10
+ background: ${recordingThemeCssVariables.background.primary};
11
+ border-radius: ${recordingThemeCssVariables.border.radiusSm};
12
+ overflow: hidden;
13
+ width: 100%;
14
+ `;
15
+
16
+ const StyledVideo = styled.video`
17
+ accent-color: ${recordingThemeCssVariables.accent.primary};
18
+ background: ${recordingThemeCssVariables.background.primary};
19
+ color-scheme: light dark;
20
+ display: block;
21
+ height: 100%;
22
+ object-fit: contain;
23
+ width: 100%;
24
+ `;
25
+
26
+ type RecordingVideoPlayerProps = {
27
+ src: string | undefined;
28
+ onTimeUpdate: (currentTimeSeconds: number) => void;
29
+ };
30
+
31
+ const RecordingVideoPlayerComponent = ({
32
+ src,
33
+ onTimeUpdate,
34
+ }: RecordingVideoPlayerProps) => {
35
+ const handleTimeUpdate = (event: SyntheticEvent<HTMLVideoElement>) => {
36
+ onTimeUpdate(event.currentTarget.currentTime);
37
+ };
38
+
39
+ return (
40
+ <StyledVideoViewport>
41
+ <StyledVideo
42
+ controls
43
+ playsInline
44
+ preload="metadata"
45
+ src={src}
46
+ onTimeUpdate={handleTimeUpdate}
47
+ />
48
+ </StyledVideoViewport>
49
+ );
50
+ };
51
+
52
+ export const RecordingVideoPlayer = memo(RecordingVideoPlayerComponent);
@@ -0,0 +1,61 @@
1
+ import styled from '@emotion/styled';
2
+ import { useMemo } from 'react';
3
+
4
+ import { TranscriptEntryListItem } from 'src/front-components/components/TranscriptEntryListItem';
5
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
6
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
7
+ import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
8
+ import { buildCalendarEventParticipantBySpeakerName } from 'src/front-components/utils/build-calendar-event-participant-by-speaker-name.util';
9
+ import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.util';
10
+ import { getCalendarEventParticipantForSpeakerName } from 'src/front-components/utils/get-calendar-event-participant-for-speaker-name.util';
11
+
12
+ const StyledTranscriptContainer = styled.div`
13
+ display: flex;
14
+ flex: 1;
15
+ flex-direction: column;
16
+ gap: ${recordingThemeCssVariables.spacing[2]};
17
+ min-height: 0;
18
+ `;
19
+
20
+ type TranscriptEntryListProps = {
21
+ entries: TranscriptEntry[];
22
+ currentTimeSeconds: number;
23
+ calendarEventParticipants: CalendarEventRecordingParticipant[];
24
+ };
25
+
26
+ export const TranscriptEntryList = ({
27
+ entries,
28
+ currentTimeSeconds,
29
+ calendarEventParticipants,
30
+ }: TranscriptEntryListProps) => {
31
+ const activeEntryIndex = findActiveTranscriptEntryIndex(
32
+ entries,
33
+ currentTimeSeconds,
34
+ );
35
+ const calendarEventParticipantBySpeakerName = useMemo(
36
+ () => buildCalendarEventParticipantBySpeakerName(calendarEventParticipants),
37
+ [calendarEventParticipants],
38
+ );
39
+
40
+ return (
41
+ <StyledTranscriptContainer>
42
+ {entries.map((entry, entryIndex) => {
43
+ const calendarEventParticipant =
44
+ getCalendarEventParticipantForSpeakerName({
45
+ speakerName: entry.speakerName,
46
+ calendarEventParticipantBySpeakerName,
47
+ });
48
+
49
+ return (
50
+ <TranscriptEntryListItem
51
+ key={entryIndex}
52
+ entry={entry}
53
+ isActive={entryIndex === activeEntryIndex}
54
+ currentTimeSeconds={currentTimeSeconds}
55
+ calendarEventParticipant={calendarEventParticipant}
56
+ />
57
+ );
58
+ })}
59
+ </StyledTranscriptContainer>
60
+ );
61
+ };
@@ -0,0 +1,115 @@
1
+ import styled from '@emotion/styled';
2
+ import { isUndefined } from '@sniptt/guards';
3
+
4
+ import { TranscriptSpeakerChip } from 'src/front-components/components/TranscriptSpeakerChip';
5
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
6
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
7
+ import {
8
+ type TranscriptEntry,
9
+ type TranscriptWord,
10
+ } from 'src/front-components/types/transcript-entry.type';
11
+ import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
12
+
13
+ const StyledEntry = styled.div<{ $isActive: boolean }>`
14
+ align-items: flex-start;
15
+ background: ${({ $isActive }) =>
16
+ $isActive
17
+ ? recordingThemeCssVariables.background.transparentBlue
18
+ : 'transparent'};
19
+ border-radius: ${recordingThemeCssVariables.border.radiusSm};
20
+ box-sizing: border-box;
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: ${recordingThemeCssVariables.spacing[2]};
24
+ justify-content: center;
25
+ padding: ${recordingThemeCssVariables.spacing[2]};
26
+ width: 100%;
27
+ `;
28
+
29
+ const StyledEntryHeader = styled.div`
30
+ align-items: center;
31
+ align-self: stretch;
32
+ display: flex;
33
+ gap: ${recordingThemeCssVariables.spacing[2]};
34
+ min-height: ${recordingThemeCssVariables.spacing[6]};
35
+ min-width: 0;
36
+ `;
37
+
38
+ const StyledTimestamp = styled.span`
39
+ color: ${recordingThemeCssVariables.font.colorTertiary};
40
+ font-size: ${recordingThemeCssVariables.font.sizeXs};
41
+ line-height: 1.4;
42
+ `;
43
+
44
+ const StyledEntryText = styled.p`
45
+ align-self: stretch;
46
+ color: ${recordingThemeCssVariables.font.colorSecondary};
47
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
48
+ line-height: 1.4;
49
+ margin: 0;
50
+ `;
51
+
52
+ const StyledWord = styled.span<{ $isSpoken: boolean }>`
53
+ color: ${({ $isSpoken }) =>
54
+ $isSpoken
55
+ ? recordingThemeCssVariables.font.colorPrimary
56
+ : recordingThemeCssVariables.font.colorSecondary};
57
+ line-height: 1.4;
58
+ transition: color 0.15s ease;
59
+ `;
60
+
61
+ type TranscriptEntryListItemProps = {
62
+ entry: TranscriptEntry;
63
+ isActive: boolean;
64
+ currentTimeSeconds: number;
65
+ calendarEventParticipant: CalendarEventRecordingParticipant | undefined;
66
+ };
67
+
68
+ export const TranscriptEntryListItem = ({
69
+ entry,
70
+ isActive,
71
+ currentTimeSeconds,
72
+ calendarEventParticipant,
73
+ }: TranscriptEntryListItemProps) => {
74
+ const speakerDisplayName =
75
+ calendarEventParticipant?.displayName ?? entry.speakerName;
76
+
77
+ return (
78
+ <StyledEntry $isActive={isActive}>
79
+ <StyledEntryHeader>
80
+ <TranscriptSpeakerChip
81
+ speakerName={speakerDisplayName}
82
+ avatarUrl={calendarEventParticipant?.avatarUrl}
83
+ placeholderColorSeed={
84
+ calendarEventParticipant?.placeholderColorSeed ?? speakerDisplayName
85
+ }
86
+ />
87
+ {!isUndefined(entry.startSeconds) && (
88
+ <StyledTimestamp>
89
+ {formatTranscriptTimestamp(entry.startSeconds)}
90
+ </StyledTimestamp>
91
+ )}
92
+ </StyledEntryHeader>
93
+ <StyledEntryText>
94
+ {entry.words.map((word, wordIndex) => (
95
+ <StyledWord
96
+ key={wordIndex}
97
+ $isSpoken={isWordSpoken({ word, currentTimeSeconds })}
98
+ >
99
+ {wordIndex > 0 ? ' ' : ''}
100
+ {word.text}
101
+ </StyledWord>
102
+ ))}
103
+ </StyledEntryText>
104
+ </StyledEntry>
105
+ );
106
+ };
107
+
108
+ const isWordSpoken = ({
109
+ word,
110
+ currentTimeSeconds,
111
+ }: {
112
+ word: TranscriptWord;
113
+ currentTimeSeconds: number;
114
+ }): boolean =>
115
+ !isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds;
@@ -0,0 +1,48 @@
1
+ import styled from '@emotion/styled';
2
+
3
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
4
+
5
+ const StyledStateContainer = styled.div`
6
+ box-sizing: border-box;
7
+ font-family: ${recordingThemeCssVariables.font.family};
8
+ height: 100%;
9
+ padding: ${recordingThemeCssVariables.spacing[4]};
10
+ `;
11
+
12
+ const StyledErrorBox = styled.div`
13
+ background: ${recordingThemeCssVariables.background.transparentDanger};
14
+ border: 1px solid ${recordingThemeCssVariables.border.colorDanger};
15
+ border-radius: ${recordingThemeCssVariables.border.radiusMd};
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: ${recordingThemeCssVariables.spacing[1]};
19
+ padding: ${recordingThemeCssVariables.spacing[3]};
20
+ `;
21
+
22
+ const StyledErrorTitle = styled.span`
23
+ color: ${recordingThemeCssVariables.font.colorDanger};
24
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
25
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
26
+ `;
27
+
28
+ const StyledErrorDescription = styled.span`
29
+ color: ${recordingThemeCssVariables.font.colorSecondary};
30
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
31
+ `;
32
+
33
+ type TranscriptErrorBoxProps = {
34
+ title: string;
35
+ description: string;
36
+ };
37
+
38
+ export const TranscriptErrorBox = ({
39
+ title,
40
+ description,
41
+ }: TranscriptErrorBoxProps) => (
42
+ <StyledStateContainer>
43
+ <StyledErrorBox>
44
+ <StyledErrorTitle>{title}</StyledErrorTitle>
45
+ <StyledErrorDescription>{description}</StyledErrorDescription>
46
+ </StyledErrorBox>
47
+ </StyledStateContainer>
48
+ );
@@ -0,0 +1,141 @@
1
+ // Duplicates minimal twenty-ui Avatar logic for this app.
2
+ // Remove once twenty-ui can be imported safely in front components.
3
+ import styled from '@emotion/styled';
4
+ import { useState } from 'react';
5
+
6
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
7
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
8
+
9
+ const AVATAR_COLOR_NAMES = [
10
+ 'red',
11
+ 'ruby',
12
+ 'crimson',
13
+ 'tomato',
14
+ 'orange',
15
+ 'amber',
16
+ 'yellow',
17
+ 'lime',
18
+ 'grass',
19
+ 'green',
20
+ 'jade',
21
+ 'mint',
22
+ 'turquoise',
23
+ 'cyan',
24
+ 'sky',
25
+ 'blue',
26
+ 'iris',
27
+ 'violet',
28
+ 'purple',
29
+ 'plum',
30
+ 'pink',
31
+ 'bronze',
32
+ 'gold',
33
+ 'brown',
34
+ 'gray',
35
+ ] as const;
36
+
37
+ const StyledAvatar = styled.div<{
38
+ $backgroundColor: string;
39
+ $color: string;
40
+ }>`
41
+ align-items: center;
42
+ background: ${({ $backgroundColor }) => $backgroundColor};
43
+ border-radius: 50px;
44
+ box-sizing: border-box;
45
+ color: ${({ $color }) => $color};
46
+ display: flex;
47
+ flex-shrink: 0;
48
+ font-size: ${recordingThemeCssVariables.font.sizeXs};
49
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
50
+ height: 16px;
51
+ justify-content: center;
52
+ line-height: 15px;
53
+ overflow: hidden;
54
+ width: 16px;
55
+ `;
56
+
57
+ const StyledAvatarImage = styled.img`
58
+ height: 100%;
59
+ object-fit: cover;
60
+ width: 100%;
61
+ `;
62
+
63
+ type TranscriptSpeakerAvatarProps = {
64
+ speakerName: string;
65
+ avatarUrl: string | undefined;
66
+ placeholderColorSeed: string;
67
+ };
68
+
69
+ const getSpeakerInitial = (speakerName: string) =>
70
+ speakerName.trim().charAt(0).toUpperCase() || '-';
71
+
72
+ export const TranscriptSpeakerAvatar = ({
73
+ speakerName,
74
+ avatarUrl,
75
+ placeholderColorSeed,
76
+ }: TranscriptSpeakerAvatarProps) => {
77
+ const [erroredAvatarUrl, setErroredAvatarUrl] = useState<string | undefined>(
78
+ undefined,
79
+ );
80
+
81
+ const shouldShowAvatarImage =
82
+ isNonEmptyString(avatarUrl) && erroredAvatarUrl !== avatarUrl;
83
+
84
+ const handleAvatarImageError = () => {
85
+ if (isNonEmptyString(avatarUrl)) {
86
+ setErroredAvatarUrl(avatarUrl);
87
+ }
88
+ };
89
+
90
+ const avatarPlaceholderColor = getAvatarPlaceholderColor({
91
+ placeholderColorSeed,
92
+ variant: 12,
93
+ });
94
+ const avatarPlaceholderBackgroundColor = getAvatarPlaceholderColor({
95
+ placeholderColorSeed,
96
+ variant: 4,
97
+ });
98
+
99
+ return (
100
+ <StyledAvatar
101
+ aria-hidden="true"
102
+ $backgroundColor={avatarPlaceholderBackgroundColor}
103
+ $color={avatarPlaceholderColor}
104
+ >
105
+ {shouldShowAvatarImage ? (
106
+ <StyledAvatarImage
107
+ src={avatarUrl}
108
+ alt=""
109
+ onError={handleAvatarImageError}
110
+ />
111
+ ) : (
112
+ getSpeakerInitial(speakerName)
113
+ )}
114
+ </StyledAvatar>
115
+ );
116
+ };
117
+
118
+ const getAvatarPlaceholderColor = ({
119
+ placeholderColorSeed,
120
+ variant,
121
+ }: {
122
+ placeholderColorSeed: string;
123
+ variant: 4 | 12;
124
+ }): string => {
125
+ const avatarColorName =
126
+ AVATAR_COLOR_NAMES[
127
+ Math.abs(hashString(placeholderColorSeed)) % AVATAR_COLOR_NAMES.length
128
+ ];
129
+
130
+ return `var(--t-color-${avatarColorName}${variant})`;
131
+ };
132
+
133
+ const hashString = (value: string): number => {
134
+ let hash = 0;
135
+
136
+ for (let valueIndex = 0; valueIndex < value.length; valueIndex++) {
137
+ hash = value.charCodeAt(valueIndex) + ((hash << 5) - hash);
138
+ }
139
+
140
+ return hash;
141
+ };
@@ -0,0 +1,51 @@
1
+ // Duplicates minimal twenty-ui Chip logic for this app.
2
+ // Remove once twenty-ui can be imported safely in front components.
3
+ import styled from '@emotion/styled';
4
+
5
+ import { TranscriptSpeakerAvatar } from 'src/front-components/components/TranscriptSpeakerAvatar';
6
+ import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
7
+
8
+ const StyledSpeakerChip = styled.span`
9
+ align-items: center;
10
+ border-radius: ${recordingThemeCssVariables.border.radiusSm};
11
+ color: ${recordingThemeCssVariables.font.colorPrimary};
12
+ display: inline-flex;
13
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
14
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
15
+ gap: ${recordingThemeCssVariables.spacing[1]};
16
+ line-height: 1.4;
17
+ max-width: 100%;
18
+ min-width: 0;
19
+ text-decoration: none;
20
+ white-space: nowrap;
21
+ `;
22
+
23
+ const StyledSpeakerName = styled.span`
24
+ flex-shrink: 1;
25
+ min-width: 0;
26
+ overflow: hidden;
27
+ text-overflow: ellipsis;
28
+ `;
29
+
30
+ type TranscriptSpeakerChipProps = {
31
+ speakerName: string;
32
+ avatarUrl: string | undefined;
33
+ placeholderColorSeed: string;
34
+ };
35
+
36
+ export const TranscriptSpeakerChip = ({
37
+ speakerName,
38
+ avatarUrl,
39
+ placeholderColorSeed,
40
+ }: TranscriptSpeakerChipProps) => {
41
+ return (
42
+ <StyledSpeakerChip>
43
+ <TranscriptSpeakerAvatar
44
+ speakerName={speakerName}
45
+ avatarUrl={avatarUrl}
46
+ placeholderColorSeed={placeholderColorSeed}
47
+ />
48
+ <StyledSpeakerName>{speakerName}</StyledSpeakerName>
49
+ </StyledSpeakerChip>
50
+ );
51
+ };
@@ -0,0 +1,40 @@
1
+ // Avoid the SDK UI entrypoint until its bundle is safe for the browser runtime.
2
+ export const recordingThemeCssVariables = {
3
+ accent: {
4
+ primary: 'var(--t-accent-accent9)',
5
+ },
6
+ background: {
7
+ primary: 'var(--t-background-primary)',
8
+ secondary: 'var(--t-background-secondary)',
9
+ transparentBlue: 'var(--t-background-transparent-blue)',
10
+ transparentDanger: 'var(--t-background-transparent-danger)',
11
+ },
12
+ border: {
13
+ colorDanger: 'var(--t-border-color-danger)',
14
+ colorLight: 'var(--t-border-color-light)',
15
+ colorMedium: 'var(--t-border-color-medium)',
16
+ radiusMd: 'var(--t-border-radius-md)',
17
+ radiusSm: 'var(--t-border-radius-sm)',
18
+ },
19
+ boxShadow: {
20
+ light: 'var(--t-box-shadow-light)',
21
+ },
22
+ font: {
23
+ colorDanger: 'var(--t-font-color-danger)',
24
+ colorPrimary: 'var(--t-font-color-primary)',
25
+ colorSecondary: 'var(--t-font-color-secondary)',
26
+ colorTertiary: 'var(--t-font-color-tertiary)',
27
+ family: 'var(--t-font-family)',
28
+ sizeMd: 'var(--t-font-size-md)',
29
+ sizeSm: 'var(--t-font-size-sm)',
30
+ sizeXs: 'var(--t-font-size-xs)',
31
+ weightMedium: 'var(--t-font-weight-medium)',
32
+ },
33
+ spacing: {
34
+ 1: 'var(--t-spacing-1)',
35
+ 2: 'var(--t-spacing-2)',
36
+ 3: 'var(--t-spacing-3)',
37
+ 4: 'var(--t-spacing-4)',
38
+ 6: 'var(--t-spacing-6)',
39
+ },
40
+ } as const;
@@ -0,0 +1,172 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
5
+ import { getAbsoluteAvatarUrl } from 'src/front-components/utils/get-absolute-avatar-url.util';
6
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
7
+
8
+ const CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT = 100;
9
+
10
+ type CalendarEventParticipantName = {
11
+ firstName?: string | null;
12
+ lastName?: string | null;
13
+ };
14
+
15
+ type CalendarEventParticipantRelatedRecord = {
16
+ id?: string | null;
17
+ avatarUrl?: string | null;
18
+ name?: CalendarEventParticipantName | null;
19
+ };
20
+
21
+ type CalendarEventParticipantNode = {
22
+ id: string;
23
+ displayName?: string | null;
24
+ handle?: string | null;
25
+ personId?: string | null;
26
+ workspaceMemberId?: string | null;
27
+ person?: CalendarEventParticipantRelatedRecord | null;
28
+ workspaceMember?: CalendarEventParticipantRelatedRecord | null;
29
+ };
30
+
31
+ type CalendarEventParticipantEdge = {
32
+ node: CalendarEventParticipantNode;
33
+ };
34
+
35
+ type UseCalendarEventParticipantsReturn = {
36
+ calendarEventParticipants: CalendarEventRecordingParticipant[];
37
+ };
38
+
39
+ export const useCalendarEventParticipants = (
40
+ calendarEventId: string | undefined,
41
+ ): UseCalendarEventParticipantsReturn => {
42
+ const [calendarEventParticipants, setCalendarEventParticipants] = useState<
43
+ CalendarEventRecordingParticipant[]
44
+ >([]);
45
+
46
+ useEffect(() => {
47
+ if (!isNonEmptyString(calendarEventId)) {
48
+ setCalendarEventParticipants([]);
49
+ return;
50
+ }
51
+
52
+ let cancelled = false;
53
+
54
+ const fetchCalendarEventParticipants = async () => {
55
+ try {
56
+ const client = new CoreApiClient();
57
+ const queryResult = await client.query({
58
+ calendarEventParticipants: {
59
+ __args: {
60
+ filter: { calendarEventId: { eq: calendarEventId } },
61
+ first: CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT,
62
+ },
63
+ edges: {
64
+ node: {
65
+ id: true,
66
+ displayName: true,
67
+ handle: true,
68
+ personId: true,
69
+ workspaceMemberId: true,
70
+ person: {
71
+ id: true,
72
+ avatarUrl: true,
73
+ name: {
74
+ firstName: true,
75
+ lastName: true,
76
+ },
77
+ },
78
+ workspaceMember: {
79
+ id: true,
80
+ avatarUrl: true,
81
+ name: {
82
+ firstName: true,
83
+ lastName: true,
84
+ },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ });
90
+
91
+ if (cancelled) {
92
+ return;
93
+ }
94
+
95
+ const calendarEventParticipantEdges = (queryResult
96
+ .calendarEventParticipants?.edges ??
97
+ []) as CalendarEventParticipantEdge[];
98
+
99
+ setCalendarEventParticipants(
100
+ calendarEventParticipantEdges.map((calendarEventParticipantEdge) =>
101
+ mapCalendarEventParticipantNode(calendarEventParticipantEdge.node),
102
+ ),
103
+ );
104
+ } catch {
105
+ if (cancelled) {
106
+ return;
107
+ }
108
+
109
+ setCalendarEventParticipants([]);
110
+ }
111
+ };
112
+
113
+ fetchCalendarEventParticipants();
114
+
115
+ return () => {
116
+ cancelled = true;
117
+ };
118
+ }, [calendarEventId]);
119
+
120
+ return { calendarEventParticipants };
121
+ };
122
+
123
+ const mapCalendarEventParticipantNode = (
124
+ calendarEventParticipantNode: CalendarEventParticipantNode,
125
+ ): CalendarEventRecordingParticipant => {
126
+ const personName = readFullName(calendarEventParticipantNode.person?.name);
127
+ const workspaceMemberName = readFullName(
128
+ calendarEventParticipantNode.workspaceMember?.name,
129
+ );
130
+ const calendarDisplayName = readOptionalString(
131
+ calendarEventParticipantNode.displayName,
132
+ );
133
+ const handle = readOptionalString(calendarEventParticipantNode.handle);
134
+
135
+ return {
136
+ id: calendarEventParticipantNode.id,
137
+ avatarUrl: getAbsoluteAvatarUrl(
138
+ calendarEventParticipantNode.person?.avatarUrl ??
139
+ calendarEventParticipantNode.workspaceMember?.avatarUrl,
140
+ ),
141
+ displayName:
142
+ personName ?? workspaceMemberName ?? calendarDisplayName ?? handle,
143
+ nameCandidates: [
144
+ calendarDisplayName,
145
+ personName,
146
+ workspaceMemberName,
147
+ handle,
148
+ ].filter((nameCandidate): nameCandidate is string =>
149
+ isNonEmptyString(nameCandidate),
150
+ ),
151
+ placeholderColorSeed:
152
+ calendarEventParticipantNode.workspaceMemberId ??
153
+ calendarEventParticipantNode.personId ??
154
+ calendarEventParticipantNode.id,
155
+ };
156
+ };
157
+
158
+ const readFullName = (
159
+ name: CalendarEventParticipantName | null | undefined,
160
+ ): string | undefined => {
161
+ const firstName = readOptionalString(name?.firstName);
162
+ const lastName = readOptionalString(name?.lastName);
163
+ const fullName = [firstName, lastName]
164
+ .filter((namePart): namePart is string => isNonEmptyString(namePart))
165
+ .join(' ');
166
+
167
+ return isNonEmptyString(fullName) ? fullName : undefined;
168
+ };
169
+
170
+ const readOptionalString = (
171
+ value: string | null | undefined,
172
+ ): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined);