snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
/e2e/.auth
/e2e/.results
/playwright-report
/test-results
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,42 @@
# Media Notes
Example Twenty app demonstrating media recording with the **standard web
APIs**: the sandbox polyfills `navigator.mediaDevices.getUserMedia` and
`MediaRecorder`, so the recording code reads like it would in any web page.
The only Twenty-specific call is `uploadFile` from
`twenty-sdk/front-component`, which stores the recorded blob in a FILES
field.
A pinned global command ("Record media note") opens a front component that
records a voice or video note with its own UI, uploads it on stop, attaches
it to a Media note record, then plays the stored file back from its signed
URL.
## Run the e2e regression test
The test drives the full flow against a local Twenty instance with Chromium's
fake media devices (no real microphone or camera needed):
Cookie sessions are credentialed, so the front only authenticates from the
API's own origin — workspace subdomains included, which the login flow lands
on. Serve the front build from the server rather than on its own port:
```sh
# from the repo root
npx nx build twenty-sdk
NODE_ENV=production npx nx build twenty-front
npx nx build twenty-server
cp -r packages/twenty-front/build packages/twenty-server/dist/front
# start:ci, not start: the watch target would rimraf dist and delete the front
npx nx start:ci twenty-server &
node packages/twenty-sdk/dist/cli.cjs app:publish --private && node packages/twenty-sdk/dist/cli.cjs app:install
# then, from this directory
FRONT_BASE_URL=http://localhost:3000 npx playwright test --project=setup --project=chromium
```
It asserts the app-owned recording timer, the playback preview, the uploaded
file reference received by the component, and the cancel path — screenshots
of each step land in `e2e/.results/screenshots/`.

View File

@@ -0,0 +1,68 @@
import { type Locator, expect, test as setup } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const AUTH_DIR = path.resolve(__dirname, '.auth');
const STORAGE_STATE = path.join(AUTH_DIR, 'user.json');
const WORKSPACE_ORIGIN_FILE = path.join(AUTH_DIR, 'workspace-origin.txt');
const LOGIN = process.env.E2E_LOGIN ?? 'tim@apple.dev';
const PASSWORD = process.env.E2E_PASSWORD ?? 'tim@apple.dev';
const WORKSPACE_NAME = process.env.E2E_WORKSPACE_NAME ?? 'Apple';
const isVisible = async (locator: Locator) =>
locator.isVisible().catch(() => false);
setup('authenticate', async ({ page }) => {
await page.goto('/');
// A fresh load shows the auth provider choice even when credentials are prefilled.
const continueWithEmail = page.getByRole('button', {
name: 'Continue with Email',
});
const emailField = page.getByPlaceholder('Email');
// Wait on the concrete auth UI rather than networkidle (flaky per Playwright).
await expect(continueWithEmail.or(emailField).first()).toBeVisible();
if (await isVisible(continueWithEmail)) {
await continueWithEmail.click();
}
if (await isVisible(emailField)) {
await emailField.fill(LOGIN);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
}
const passwordField = page.getByPlaceholder('Password');
await passwordField.waitFor({ state: 'visible' });
await passwordField.fill(PASSWORD);
const signInButton = page.getByRole('button', { name: 'Sign in' });
await expect(signInButton).toBeEnabled();
await signInButton.click();
// Multi-workspace logins land on a picker; single-workspace logins skip it.
const workspacePicker = page.getByText('Choose a workspace');
const reachedPicker = await workspacePicker
.waitFor({ state: 'visible', timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (reachedPicker) {
await page.getByText(WORKSPACE_NAME, { exact: true }).click();
await page.waitForFunction(() => window.location.href.includes('verify'));
await page.waitForFunction(() => !window.location.href.includes('verify'));
}
await page.waitForFunction(
() => window.localStorage.getItem('tokenPairState') !== null,
undefined,
{ timeout: 15_000 },
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
fs.writeFileSync(WORKSPACE_ORIGIN_FILE, new URL(page.url()).origin);
await page.context().storageState({ path: STORAGE_STATE });
});

View File

@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { MEDIA_NOTES_TEST_IDS } from '../src/components/media-notes-test-ids';
const WORKSPACE_ORIGIN_FILE = path.resolve(
__dirname,
'.auth',
'workspace-origin.txt',
);
const SCREENSHOT_DIR = path.resolve(__dirname, '.results', 'screenshots');
const resolveWorkspaceUrl = (): string => {
const fromEnv = process.env.E2E_WORKSPACE_URL;
if (fromEnv) {
return fromEnv.replace(/\/$/, '');
}
try {
return fs
.readFileSync(WORKSPACE_ORIGIN_FILE, 'utf8')
.trim()
.replace(/\/$/, '');
} catch {
return 'http://app.localhost:3001';
}
};
test.describe('Media notes capture flow', () => {
test.beforeAll(() => {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
});
// The front component runs in a sandboxed worker; surface browser output so
// failures inside it are diagnosable from the test log.
test.beforeEach(({ page }) => {
page.on('console', (message) => {
console.log(`[browser:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
console.log(`[pageerror] ${error.message}`);
});
page.on('response', (response) => {
if (response.status() >= 400) {
console.log(
`[response ${response.status()}] ${response.request().method()} ${response.url()}`,
);
}
});
});
const openMediaNotesComponent = async (
page: import('@playwright/test').Page,
) => {
await page.goto(`${resolveWorkspaceUrl()}/`);
// The pinned global command renders as a top bar action button.
await page
.getByRole('button', { name: 'Record media note' })
.first()
.click();
await expect(page.getByTestId(MEDIA_NOTES_TEST_IDS.root)).toBeVisible();
};
test('records an audio note end to end', async ({ page }) => {
await openMediaNotesComponent(page);
await page.getByTestId(MEDIA_NOTES_TEST_IDS.recordAudioButton).click();
// The recording UX is the app's own UI: its timer and stop control.
const stopButton = page.getByTestId(
MEDIA_NOTES_TEST_IDS.stopRecordingButton,
);
await expect(stopButton).toBeVisible();
await expect(
page.getByTestId(MEDIA_NOTES_TEST_IDS.recordingTimer),
).toBeVisible();
await page.waitForTimeout(2500);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, '01-recording.png'),
});
await stopButton.click();
// Uploaded: the component receives a playable file reference.
await expect(
page.getByTestId(MEDIA_NOTES_TEST_IDS.captureStatus),
).toHaveText('captured');
const capturedAudio = page.getByTestId(MEDIA_NOTES_TEST_IDS.capturedAudio);
await expect(capturedAudio).toBeVisible();
const audioSrc = await capturedAudio.getAttribute('src');
expect(audioSrc).toBeTruthy();
expect(audioSrc).toContain('/file');
// Actually fetch it: a signed url for a folder the file guard does not
// serve still looks correct in the src attribute but answers 401/403, so
// asserting on the string alone would not catch an unplayable recording.
const mediaResponse = await page.request.get(audioSrc as string);
expect(mediaResponse.status()).toBe(200);
// The body, not content-length: the file route streams the response, so
// the header is absent even on a perfectly good recording.
expect((await mediaResponse.body()).length).toBeGreaterThan(0);
// The recording is attached to a record, which is what makes the
// uploaded file permanent instead of a temporary orphan.
await expect(
page.getByTestId(MEDIA_NOTES_TEST_IDS.savedRecord),
).toContainText('Attached to media note');
await page.screenshot({
path: path.join(SCREENSHOT_DIR, '02-captured.png'),
});
});
test('cancelling a recording resolves as cancelled', async ({ page }) => {
await openMediaNotesComponent(page);
await page.getByTestId(MEDIA_NOTES_TEST_IDS.recordAudioButton).click();
await expect(
page.getByTestId(MEDIA_NOTES_TEST_IDS.recordingTimer),
).toBeVisible();
await page.getByTestId(MEDIA_NOTES_TEST_IDS.cancelRecordingButton).click();
await expect(
page.getByTestId(MEDIA_NOTES_TEST_IDS.captureStatus),
).toHaveText('cancelled');
});
});

View File

@@ -0,0 +1,34 @@
{
"name": "media-notes",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.24.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"playwright": "^1.60.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.32.0",
"twenty-sdk": "2.32.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,55 @@
import { defineConfig, devices } from '@playwright/test';
import * as path from 'path';
// Front-end base URL of the running Twenty instance under test.
const FRONT_BASE_URL = process.env.FRONT_BASE_URL ?? 'http://localhost:3001';
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/.results',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
timeout: 90 * 1000,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: FRONT_BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
headless: true,
testIdAttribute: 'data-testid',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
},
},
expect: {
timeout: 15_000,
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Chromium only: the fake media device flags that make getUserMedia and
// MediaRecorder deterministic in CI are Chromium-specific.
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'e2e/.auth/user.json'),
permissions: ['microphone', 'camera'],
launchOptions: {
// Lets sandboxed environments point at a preinstalled Chromium
// instead of downloading the pinned revision.
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
],
},
},
dependencies: ['setup'],
},
],
});

View File

@@ -0,0 +1,13 @@
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'c832302c-e551-4b4f-b11c-19907888a284';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Media Notes',
description:
'Example app demonstrating the recordAudio / recordVideo front component capability',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,13 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import { MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../components/media-notes.front-component';
export default defineCommandMenuItem({
universalIdentifier: '83d9d1ba-b042-41c1-94e8-892931d8663f',
label: 'Record media note',
shortLabel: 'Media note',
icon: 'IconMicrophone',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,15 @@
export const MEDIA_NOTES_TEST_IDS = {
root: 'media-notes-root',
recordAudioButton: 'media-notes-record-audio',
recordVideoButton: 'media-notes-record-video',
recordingTimer: 'media-notes-recording-timer',
stopRecordingButton: 'media-notes-stop-recording',
cancelRecordingButton: 'media-notes-cancel-recording',
captureStatus: 'media-notes-capture-status',
capturedFile: 'media-notes-captured-file',
capturedAudio: 'media-notes-captured-audio',
capturedVideo: 'media-notes-captured-video',
savedRecord: 'media-notes-saved-record',
attachError: 'media-notes-attach-error',
retryAttachButton: 'media-notes-retry-attach',
};

View File

@@ -0,0 +1,649 @@
import { useEffect, useRef, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
type UploadedFrontComponentFile,
uploadFile,
} from 'twenty-sdk/front-component';
import { RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/media-note.object';
import { MEDIA_NOTES_TEST_IDS } from './media-notes-test-ids';
export const MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'14695714-35b5-4814-9489-f65c12368b5c';
const buttonStyle: React.CSSProperties = {
backgroundColor: '#1961ed',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
marginRight: '8px',
padding: '8px 12px',
};
const OBJECTS_PAGE_SIZE = 100;
const MAX_RECORDING_DURATION_SECONDS = 60;
type ObjectsPage = {
fields: { id: string; universalIdentifier: string | null }[];
nextCursor: string | null;
};
const fetchObjectsPage = async (after: string | null): Promise<ObjectsPage> => {
const result = await new MetadataApiClient().query({
objects: {
__args: {
paging: { first: OBJECTS_PAGE_SIZE, ...(after ? { after } : {}) },
filter: {},
},
pageInfo: { hasNextPage: true, endCursor: true },
edges: {
node: { fieldsList: { id: true, universalIdentifier: true } },
},
},
});
const pageInfo = result.objects.pageInfo;
return {
fields: result.objects.edges.flatMap((edge) => edge.node.fieldsList ?? []),
nextCursor:
pageInfo.hasNextPage === true ? (pageInfo.endCursor ?? null) : null,
};
};
// An app declares its fields by universalIdentifier; the per-instance
// fieldMetadataId the recording upload needs is resolved at runtime. The
// metadata API cannot filter on universalIdentifier, so page through the
// object list until the field turns up.
const fetchRecordingFieldMetadataId = async (): Promise<string | null> => {
let cursor: string | null = null;
for (;;) {
const page: ObjectsPage = await fetchObjectsPage(cursor);
const recordingField = page.fields.find(
(field) =>
field.universalIdentifier === RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
);
if (recordingField) {
return recordingField.id;
}
if (page.nextCursor === null) {
return null;
}
cursor = page.nextCursor;
}
};
const MIME_TYPE_TO_FILE_EXTENSION: Record<string, string> = {
'audio/webm': 'webm',
'video/webm': 'webm',
'audio/mp4': 'm4a',
'video/mp4': 'mp4',
'audio/ogg': 'ogg',
'video/ogg': 'ogg',
};
const getFileExtension = (mimeType: string): string =>
MIME_TYPE_TO_FILE_EXTENSION[mimeType.split(';')[0].trim().toLowerCase()] ??
'webm';
// The recording engine is the standard web platform: getUserMedia and
// MediaRecorder, polyfilled into the sandbox by the host. Errors surface as
// DOMException names exactly like in a regular page.
const mapMediaErrorNameToReason = (errorName: string): string => {
switch (errorName) {
case 'NotAllowedError':
case 'SecurityError':
return 'permission-denied';
case 'NotFoundError':
case 'OverconstrainedError':
return 'no-device';
case 'NotReadableError':
return 'busy';
case 'NotSupportedError':
return 'blocked';
default:
return 'unknown';
}
};
const mapMediaErrorToReason = (error: unknown): string =>
error instanceof Error ? mapMediaErrorNameToReason(error.name) : 'unknown';
type CapturedMediaFile = UploadedFrontComponentFile & {
durationSeconds: number;
};
type CaptureResult =
| { status: 'captured'; file: CapturedMediaFile }
| { status: 'cancelled' }
| { status: 'failed'; reason: string };
const describeResult = (result: CaptureResult): string =>
result.status === 'failed' ? `failed:${result.reason}` : result.status;
type PendingAttach = {
mediaType: 'audio' | 'video';
file: CapturedMediaFile;
};
type ActiveRecording = {
mediaType: 'audio' | 'video';
startedAt: number;
mediaStream: MediaStream;
mediaRecorder: MediaRecorder;
};
type RecordingSession = {
wasCancelled: boolean;
isUserStopping: boolean;
errorName: string | null;
collectRecordedBlob: Promise<Blob>;
};
const MediaNotes = () => {
const [recordingFieldMetadataId, setRecordingFieldMetadataId] = useState<
string | null
>(null);
const [captureResult, setCaptureResult] = useState<CaptureResult | null>(
null,
);
const [activeRecording, setActiveRecording] =
useState<ActiveRecording | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [savedRecordId, setSavedRecordId] = useState<string | null>(null);
// Set only when attaching failed, so it doubles as the retry payload.
const [failedAttach, setFailedAttach] = useState<PendingAttach | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
// Covers the stop-and-upload window: without it the record buttons come
// back while the previous recording is still uploading, and two flows race.
const [isStopping, setIsStopping] = useState(false);
const recordingSessionRef = useRef<RecordingSession | null>(null);
const stopAndSaveRef = useRef<(() => void) | null>(null);
// Synchronous guard: a second click during the getUserMedia permission
// prompt would otherwise start a second capture.
const isStartingRef = useRef(false);
useEffect(() => {
fetchRecordingFieldMetadataId()
.then(setRecordingFieldMetadataId)
.catch(() => setRecordingFieldMetadataId(null));
}, []);
// The recording UX is the app's own: this timer is rendered and styled
// here, and the app also owns its duration ceiling — the host imposes
// none.
useEffect(() => {
if (activeRecording === null) {
return;
}
setElapsedSeconds(0);
const elapsedInterval = setInterval(() => {
const nextElapsedSeconds = Math.floor(
(Date.now() - activeRecording.startedAt) / 1000,
);
setElapsedSeconds(nextElapsedSeconds);
if (nextElapsedSeconds >= MAX_RECORDING_DURATION_SECONDS) {
stopAndSaveRef.current?.();
}
}, 1000);
return () => clearInterval(elapsedInterval);
}, [activeRecording]);
// Attaching the uploaded file to a record is what makes it permanent: until
// then it is a temporary file owned by the FILES field, so a failure here
// has to be recoverable rather than silent.
const attachToNewMediaNote = async (pendingAttach: PendingAttach) => {
setFailedAttach(null);
setIsAttaching(true);
try {
const created = await new CoreApiClient().mutation({
createMediaNote: {
__args: {
data: {
title: `${pendingAttach.mediaType} note`,
recording: [
{
fileId: pendingAttach.file.fileId,
label: pendingAttach.file.path,
},
],
},
},
id: true,
},
});
setSavedRecordId(created.createMediaNote?.id ?? null);
} catch {
setFailedAttach(pendingAttach);
} finally {
setIsAttaching(false);
}
};
const handleStartRecording = async (mediaType: 'audio' | 'video') => {
// Starting a new recording mid-attach would let the in-flight attach
// settle against a recording the UI has already replaced, offering a
// retry for the wrong file.
if (
recordingFieldMetadataId === null ||
isAttaching ||
isStopping ||
isStartingRef.current ||
activeRecording !== null
) {
return;
}
isStartingRef.current = true;
setCaptureResult(null);
setSavedRecordId(null);
setFailedAttach(null);
let acquiredMediaStream: MediaStream | null = null;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia(
mediaType === 'audio' ? { audio: true } : { video: true, audio: true },
);
acquiredMediaStream = mediaStream;
const mediaRecorder = new MediaRecorder(mediaStream);
const recordedChunks: Blob[] = [];
const recordingSession: RecordingSession = {
wasCancelled: false,
isUserStopping: false,
errorName: null,
collectRecordedBlob: new Promise<Blob>((resolve) => {
mediaRecorder.onstop = () =>
resolve(
new Blob(recordedChunks, {
type:
mediaRecorder.mimeType ||
(mediaType === 'audio' ? 'audio/webm' : 'video/webm'),
}),
);
}),
};
mediaRecorder.ondataavailable = (event) => {
const dataEvent = event as Event & { data: Blob };
if (dataEvent.data.size > 0) {
recordedChunks.push(dataEvent.data);
}
};
mediaRecorder.onerror = (event) => {
const errorEvent = event as Event & { error?: Error };
recordingSession.errorName = errorEvent.error?.name ?? 'UnknownError';
};
// A stop the app did not ask for means the recorder failed to start on
// the host or died mid-recording (it fires error then stop, like the
// native API): release the devices and surface the failure.
mediaRecorder.addEventListener('stop', () => {
if (recordingSession.isUserStopping || recordingSession.wasCancelled) {
return;
}
recordingSession.wasCancelled = true;
recordingSessionRef.current = null;
for (const track of mediaStream.getTracks()) {
track.stop();
}
setActiveRecording(null);
setCaptureResult({
status: 'failed',
reason:
recordingSession.errorName === null
? 'unknown'
: mapMediaErrorNameToReason(recordingSession.errorName),
});
});
// The host indicator's stop button (or a revoked device) surfaces as
// standard track ended events; the app reacts by discarding, without
// any bespoke callback from the host.
for (const track of mediaStream.getTracks()) {
track.onended = () => {
if (recordingSession.wasCancelled) {
return;
}
recordingSession.wasCancelled = true;
recordingSessionRef.current = null;
// One dead track ends the whole take: stop the recorder and the
// remaining tracks so nothing keeps capturing behind the
// cancelled state.
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
for (const remainingTrack of mediaStream.getTracks()) {
if (remainingTrack.readyState === 'live') {
remainingTrack.stop();
}
}
setActiveRecording(null);
setCaptureResult({ status: 'cancelled' });
};
}
mediaRecorder.start();
recordingSessionRef.current = recordingSession;
setActiveRecording({
mediaType,
startedAt: Date.now(),
mediaStream,
mediaRecorder,
});
} catch (error) {
// The recorder can fail after the devices were granted; do not leave
// them capturing behind a failure message.
if (acquiredMediaStream !== null) {
for (const track of acquiredMediaStream.getTracks()) {
track.stop();
}
}
setCaptureResult({
status: 'failed',
reason: mapMediaErrorToReason(error),
});
} finally {
isStartingRef.current = false;
}
};
const handleStopRecording = async () => {
if (activeRecording === null) {
return;
}
const { mediaRecorder, mediaStream, mediaType, startedAt } =
activeRecording;
const recordingSession = recordingSessionRef.current;
if (recordingSession) {
recordingSession.isUserStopping = true;
}
setActiveRecording(null);
setIsStopping(true);
try {
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
const recordedBlob = recordingSession
? await recordingSession.collectRecordedBlob
: null;
for (const track of mediaStream.getTracks()) {
track.stop();
}
if (
recordedBlob === null ||
recordingSession === null ||
recordingSession.wasCancelled
) {
setCaptureResult({ status: 'cancelled' });
return;
}
// An errored recorder still resolves the stop, but with nothing worth
// uploading.
if (recordingSession.errorName !== null || recordedBlob.size === 0) {
setCaptureResult({
status: 'failed',
reason:
recordingSession.errorName === null
? 'unknown'
: mapMediaErrorNameToReason(recordingSession.errorName),
});
return;
}
recordingSessionRef.current = null;
const durationSeconds = Math.max(
1,
Math.round((Date.now() - startedAt) / 1000),
);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const uploadResult = await uploadFile(recordedBlob, {
fieldMetadataId: recordingFieldMetadataId ?? '',
fileName: `${mediaType}-recording-${timestamp}.${getFileExtension(recordedBlob.type)}`,
});
if (uploadResult.status === 'failed') {
setCaptureResult({ status: 'failed', reason: uploadResult.reason });
return;
}
const capturedFile: CapturedMediaFile = {
...uploadResult.file,
durationSeconds,
};
setCaptureResult({ status: 'captured', file: capturedFile });
await attachToNewMediaNote({ mediaType, file: capturedFile });
} finally {
setIsStopping(false);
}
};
stopAndSaveRef.current = handleStopRecording;
const handleCancelRecording = async () => {
if (activeRecording === null) {
return;
}
const { mediaRecorder, mediaStream } = activeRecording;
const recordingSession = recordingSessionRef.current;
if (recordingSession) {
recordingSession.wasCancelled = true;
}
recordingSessionRef.current = null;
setActiveRecording(null);
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
for (const track of mediaStream.getTracks()) {
track.stop();
}
setCaptureResult({ status: 'cancelled' });
};
return (
<div
data-testid={MEDIA_NOTES_TEST_IDS.root}
style={{ fontFamily: 'sans-serif', padding: '16px' }}
>
<h3 style={{ fontSize: '15px', margin: '0 0 12px' }}>Media notes</h3>
<p style={{ color: '#555', fontSize: '13px', margin: '0 0 16px' }}>
Record a note with your microphone or camera. The recording is stored in
Twenty and attached to a Media note record.
</p>
{activeRecording === null ? (
<div style={{ marginBottom: '16px' }}>
<button
data-testid={MEDIA_NOTES_TEST_IDS.recordAudioButton}
style={buttonStyle}
disabled={
recordingFieldMetadataId === null || isAttaching || isStopping
}
onClick={() => handleStartRecording('audio')}
>
Record a voice note
</button>
<button
data-testid={MEDIA_NOTES_TEST_IDS.recordVideoButton}
style={buttonStyle}
disabled={
recordingFieldMetadataId === null || isAttaching || isStopping
}
onClick={() => handleStartRecording('video')}
>
Record a video note
</button>
</div>
) : (
<div
style={{
alignItems: 'center',
display: 'flex',
gap: '8px',
marginBottom: '16px',
}}
>
<span
style={{
backgroundColor: '#b42318',
borderRadius: '50%',
display: 'inline-block',
height: '10px',
width: '10px',
}}
/>
<span
data-testid={MEDIA_NOTES_TEST_IDS.recordingTimer}
style={{ fontSize: '13px', fontVariantNumeric: 'tabular-nums' }}
>
Recording {activeRecording.mediaType}… {elapsedSeconds}s
</span>
<button
data-testid={MEDIA_NOTES_TEST_IDS.stopRecordingButton}
style={buttonStyle}
onClick={handleStopRecording}
>
Stop and save
</button>
<button
data-testid={MEDIA_NOTES_TEST_IDS.cancelRecordingButton}
style={{ ...buttonStyle, backgroundColor: '#666' }}
onClick={handleCancelRecording}
>
Cancel
</button>
</div>
)}
{captureResult !== null && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.captureStatus}
style={{ color: '#333', fontSize: '13px', marginBottom: '12px' }}
>
{describeResult(captureResult)}
</div>
)}
{captureResult?.status === 'captured' && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.capturedFile}
style={{
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '12px',
padding: '12px',
}}
>
<p style={{ margin: '0 0 8px' }}>
{captureResult.file.mimeType} · {captureResult.file.durationSeconds}
s · {Math.round(captureResult.file.size / 1024)} KB
</p>
{captureResult.file.mimeType.startsWith('audio/') ? (
<audio
data-testid={MEDIA_NOTES_TEST_IDS.capturedAudio}
controls
src={captureResult.file.url}
style={{ width: '100%' }}
/>
) : (
<video
data-testid={MEDIA_NOTES_TEST_IDS.capturedVideo}
controls
src={captureResult.file.url}
style={{ maxHeight: '200px', width: '100%' }}
/>
)}
{savedRecordId !== null && (
<p
data-testid={MEDIA_NOTES_TEST_IDS.savedRecord}
style={{ color: '#555', margin: '8px 0 0' }}
>
Attached to media note {savedRecordId}
</p>
)}
{failedAttach !== null && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.attachError}
style={{ color: '#b42318', margin: '8px 0 0' }}
>
<p style={{ margin: '0 0 8px' }}>
The recording was uploaded but could not be attached to a media
note. It stays temporary until it is attached.
</p>
<button
data-testid={MEDIA_NOTES_TEST_IDS.retryAttachButton}
style={buttonStyle}
disabled={isAttaching}
onClick={() => attachToNewMediaNote(failedAttach)}
>
Retry attaching
</button>
</div>
)}
</div>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'media-notes-component',
description: 'Record audio or video notes with standard web media APIs',
component: MediaNotes,
});

View File

@@ -0,0 +1,41 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
export const MEDIA_NOTE_UNIVERSAL_IDENTIFIER =
'68d3bcc0-1a6c-4e7d-9fc3-c7bc57b8241d';
export const TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'c1d4e7a2-3b56-4c89-9e12-4f6a8b0d3e75';
export const RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'd2e5f8b3-4c67-4d9a-8f23-5a7b9c1e4f86';
export default defineObject({
universalIdentifier: MEDIA_NOTE_UNIVERSAL_IDENTIFIER,
nameSingular: 'mediaNote',
namePlural: 'mediaNotes',
labelSingular: 'Media note',
labelPlural: 'Media notes',
description: 'A note captured with the microphone or camera',
icon: 'IconMicrophone',
labelIdentifierFieldMetadataUniversalIdentifier:
TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: TITLE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
label: 'Title',
description: "The media note's title",
icon: 'IconAbc',
name: 'title',
},
{
universalIdentifier: RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
label: 'Recording',
description: 'The captured audio or video note',
icon: 'IconMicrophone',
name: 'recording',
universalSettings: { maxNumberOfValues: 10 },
},
],
});

View File

@@ -0,0 +1,28 @@
import { defineRole } from 'twenty-sdk/define';
import { MEDIA_NOTE_UNIVERSAL_IDENTIFIER } from '../objects/media-note.object';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'04981304-eca8-4757-9e9a-254390eafc79';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Media notes default role',
description: 'Default role for the media notes example app',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: MEDIA_NOTE_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
});

View File

@@ -0,0 +1,39 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"e2e",
"playwright.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
]
}