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 3s

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,5 @@
# Credentials for integration tests. Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=

View File

@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# 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*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
!css.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,20 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.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"
}
}

View File

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

View File

@@ -0,0 +1,66 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view unavailable on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,66 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view unavailable on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,64 @@
# Call Recorder
**Record, transcribe, and save every meeting — right inside your CRM.**
## ✨ What you get
- **Recordings on every meeting**
- **A Call Recording tab**
- **A per-meeting on/off switch**
- **Customizable in-meeting recording notices**
- **AI meeting summaries**
- **Built for AI & automation**
## 💳 Billing
Metered in Twenty credits based on the bot's actual recording time, prorated by
duration — **$1.00 per recording-hour** (1 credit). No recording — opted out,
canceled, or no-show — means no charge.
AI summaries use workspace AI credits, billed on the model's token usage — the
cost scales with how much was said in the meeting, typically **$0.02–$0.06 per
meeting** on default models. Set the `CALL_RECORDER_SUMMARY_ENABLED` app
variable to `false` to turn summaries off.
## 🤖 The Recording Bot field
Each calendar event has a **Recording Bot** field. It starts blank and the app
sets it to On when it actually schedules the recorder for that meeting, which
happens once the meeting is within the seven-day scheduling window. Set it to
Off to skip a meeting. Past meetings the recorder never joined simply stay
blank.
## 🗓️ Pausing the recorder
The **Record my calendar meetings** toggle in the app settings is on
by default. Turn it off to stop scheduling bots for upcoming meetings and cancel
every recording that is already scheduled; the Recording Bot field of those
meetings goes back to blank, and an Off you set yourself is left alone. Turn it
back on and the app sweeps upcoming meetings to schedule bots again.
## 🎥 Supported meeting platforms
The recording bot can only join meetings on these platforms:
- ✅ Google Meet
- ✅ Zoom
- ✅ Microsoft Teams
- ✅ Webex
- ✅ GoTo Meeting
Events whose conference link points to any other platform (e.g. ro.am, Daily,
Whereby) or that only have a dial-in number are **ignored** — no bot is
scheduled, since it can't join the call.
Recording notices are supported in Zoom, Google Meet, and Microsoft Teams.
Workspace admins can turn them off or customize their text in the Call Recorder
app settings. Webex and GoTo Meeting do not support bot chat messages.
## 📌 Heads up
- **Needs a synced calendar + video link** — ad-hoc calls that were never on
your Google, Outlook, or CalDAV calendar aren't recorded.
- **Your copy is yours** — Twenty stores its own video, audio, and transcript,
so they stay available after the source media expires.

View File

@@ -0,0 +1,108 @@
# Call Recorder — Self-hosting setup
This guide is for Twenty **server admins**. It covers connecting Call Recorder
to the recording service (Recall.ai), the environment variables it reads, and
how to wire up the webhook.
If you're on **Twenty Cloud**, these credentials may already be configured —
check with your workspace before setting them.
## What you need to wire up
Call Recorder talks to [Recall.ai](https://recall.ai) to send bots to meetings
and to receive recordings back. Two things must be configured:
1. A **Recall.ai API key** (and optionally a region), so the app can schedule,
update, and cancel bots.
2. A **webhook** from Recall.ai back to your deployment, so the app learns when
a recording is ready and can ingest it.
Transcription runs on Recall.ai's own provider by default, which needs no extra
credentials. If a workspace switches `CALL_RECORDER_TRANSCRIPT_PROVIDER` to
Gladia, add a **Gladia API key** in the Recall.ai dashboard
(**Transcription → Gladia**) for every region in use, or transcripts for that
workspace fail.
## Server variables
Set these on the application registration after installing
(**Settings → Applications → Call Recorder**):
| Server variable | Required | Purpose |
|---|---|---|
| `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. |
| `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1` (Europe / Frankfurt). |
| `CALL_RECORDER_RECORDING_RETENTION_HOURS` | No | How long Recall.ai retains the source media after processing. Defaults to `166` hours (6 days 22 hours), just under Recall's 168-hour free-storage window. Values above `168` may incur Recall storage charges. Twenty's ingested copy is unaffected. |
| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. |
> **Calendar scheduling** (`CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED`,
> the "Record my calendar meetings" toggle; turning it off cancels
> every scheduled recording), **bot behavior settings** (display name,
> recording notice, join timing, lobby and leave timeouts), the transcription
> provider
> (`CALL_RECORDER_TRANSCRIPT_PROVIDER`) and the summary settings
> (`CALL_RECORDER_SUMMARY_ENABLED`,
> `CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT`) are **application variables** that
> a workspace admin tunes inside the app — not server variables.
## Configuring the Recall webhook
The app exposes a server webhook route that verifies the Recall/Svix signature,
advances the matching recording's lifecycle status (`JOINING` → `RECORDING` →
`PROCESSING`, or `FAILED`), and — once the recording finishes — ingests the
audio, video, and transcript. It never moves a status backward, so out-of-order
or duplicate deliveries are safe, and it returns a non-2xx response on signature
failures so Recall retries.
Use this URL on your deployment, replacing only the host:
```text
https://<your-twenty-host>/webhooks/server/9215afe6-1497-4149-a49d-e608e239bbaf
```
The ID is the **Recall webhook logic function**.
1. In the Recall.ai dashboard, create a webhook endpoint pointing at your
deployment's webhook URL, subscribed to the **bot status-change**,
**recording**, and **transcript** events (`bot.status_change`,
`recording.done`, `recording.failed`, `transcript.done`,
`transcript.failed`). Status-change drives the lifecycle; the recording and
transcript events trigger media and transcript ingestion. Subscribing to
status changes alone leaves ingestion to the reconciliation backstop.
2. Copy the endpoint's signing secret — it starts with `whsec_`.
3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the **Call Recorder**
application registration.
4. Set `RECALL_API_KEY` (and optionally `RECALL_REGION`) the same way.
## Recording lifecycle
Each recording is stored as a **CallRecording** record and moves through a
lifecycle status: `SCHEDULED` → `JOINING` → `RECORDING` → `PROCESSING` →
`COMPLETED`, or `FAILED` (with a failure reason when one is available).
A recording reaches `COMPLETED` only once **both** its audio and video have been
ingested. Recall produces only the artifacts requested at bot creation (mixed
MP3 + MP4); if processing fails, the recording is marked `FAILED`.
Recall.ai retains the source media for a limited window (about seven days by
default) to stay inside its free-storage window. Twenty ingests and stores the
video, audio, and transcript in its own storage, so they remain available after
Recall's media expires.
A periodic reconciliation job runs as a safety net, pulling the latest status
from Recall and keeping recordings and bots in sync even when a real-time
webhook update is missed.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No bot joined a meeting | **Recording** was off, the event had no conference link, it wasn't synced from a connected calendar, or `RECALL_API_KEY` isn't set | Confirm the event is on, upcoming, has a video link, and came from a synced calendar; confirm `RECALL_API_KEY` is set |
| Recording never reaches `COMPLETED` | A Recall webhook was missed, or only one of audio/video was produced | The reconciliation job pulls the latest status from Recall within a few minutes; if it is marked `FAILED`, inspect the bot in the Recall dashboard |
| Transcript empty, or marked pending/failed | Recall hasn't finished async transcription yet, or transcription failed for that call | Wait for the reconciliation job to ingest the transcript; a persistent failure leaves a marker in the transcript |
| Webhook rejected with `500` (`Invalid webhook signature`, Recall keeps retrying) | `RECALL_WEBHOOK_SECRET` doesn't match the Recall endpoint's signing secret | Re-copy the `whsec_…` secret from the Recall webhook endpoint into the `RECALL_WEBHOOK_SECRET` server variable |
| Webhook rejected with `500` (`RECALL_WEBHOOK_SECRET … not set`) | `RECALL_WEBHOOK_SECRET` is not set | Set it on the application registration |
| Bot left almost immediately | No one was admitted before the lobby / empty-meeting timeout, or everyone left | Adjust the lobby / empty-meeting timeouts in the app settings (see **Customize the bot** in the README) if they're too aggressive |
| Bot joined a meeting you didn't want recorded | Recording is on by default | Set the event's **Recording** field to Off; the scheduled bot is canceled |
| Summary stays empty after the transcript arrives | Summaries are disabled, or the summarizer run failed (for example, out of AI credits) | Confirm `CALL_RECORDER_SUMMARY_ENABLED` isn't `false` and the workspace has AI credits |
| Summary shows "No summary available." | The transcript was empty or unintelligible | No action needed; this is the expected outcome for low-quality transcripts |

View File

@@ -0,0 +1 @@
declare module '*.css';

View File

@@ -0,0 +1,46 @@
{
"name": "@twentyhq/call-recorder",
"version": "1.13.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.37.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit:watch": "vitest --config vitest.unit.config.ts",
"test:watch": "vitest"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"sharp": "^0.35.3"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.37.0",
"twenty-sdk": "2.37.0",
"twenty-ui": "^1.0.0-alpha.1",
"typescript": "^5.9.3",
"use-debounce": "^10.0.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.1.9"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,5 @@
<svg width="136" height="136" viewBox="0 0 136 136" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="136" height="136" fill="#D9F3EE"/>
<path d="M75.9072 46.0928C76.7667 46.9522 77.2499 48.1176 77.25 49.333V86.667C77.2499 87.8824 76.7667 89.0478 75.9072 89.9072C75.0478 90.7667 73.8824 91.2499 72.667 91.25H35.333C34.1176 91.2499 32.9522 90.7667 32.0928 89.9072C31.2333 89.0478 30.7501 87.8825 30.75 86.667V50.2044C30.75 49.6487 30.5188 49.1181 30.1119 48.7397L22.9309 42.0629C22.2912 41.4681 21.25 41.9218 21.25 42.7953V86.667C21.2501 90.402 22.7339 93.9839 25.375 96.625C28.0161 99.2661 31.598 100.75 35.333 100.75H72.667C76.402 100.75 79.9839 99.2661 82.625 96.625C85.2661 93.9839 86.7499 90.402 86.75 86.667V49.333C86.7499 45.598 85.2661 42.0161 82.625 39.375C79.9839 36.7339 76.402 35.2501 72.667 35.25H27.157C26.2342 35.25 25.8038 36.3934 26.4977 37.0018L34.7671 44.2537C35.1319 44.5736 35.6005 44.75 36.0857 44.75H72.667C73.8825 44.7501 75.0478 45.2333 75.9072 46.0928Z" fill="#12A594"/>
<path d="M104.757 42.8125C106.259 42.8802 107.723 43.3068 109.024 44.0547L109.283 44.208L109.536 44.3701C110.704 45.1443 111.685 46.1699 112.406 47.3711L112.557 47.6309L112.699 47.8955C113.389 49.2291 113.749 50.7108 113.75 52.2149V83.7852C113.749 85.3894 113.338 86.9672 112.557 88.3682C111.775 89.7692 110.648 90.9477 109.283 91.791C107.918 92.6344 106.36 93.1143 104.757 93.1865C103.154 93.2587 101.559 92.9203 100.124 92.2031L92.1058 88.1953C91.4281 87.8566 91 87.164 91 86.4063V78.6384C91 77.8951 91.7823 77.4116 92.4472 77.744L104.25 83.6445V52.3535L92.4471 58.2532C91.7822 58.5856 91 58.1021 91 57.3588V49.5926C91 48.835 91.4281 48.1425 92.1057 47.8037L100.123 43.7959C101.558 43.0788 103.154 42.7404 104.757 42.8125Z" fill="#12A594"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import applicationConfig from 'src/application-config';
import { CALL_RECORDER_MAPPED_VARIABLE_KEYS } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { RECALL_ASYNC_TRANSCRIPT_PROVIDERS } from 'src/logic-functions/constants/recall-async-transcript-providers';
describe('application config', () => {
it('lays out every application variable in the settings component', () => {
expect([...CALL_RECORDER_MAPPED_VARIABLE_KEYS].sort()).toEqual(
Object.keys(applicationConfig.config.applicationVariables ?? {}).sort(),
);
});
it('offers every supported transcript provider as a select option', () => {
const options =
applicationConfig.config.applicationVariables?.[
CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME
]?.options ?? [];
expect(options.map((option) => option.value).sort()).toEqual(
Object.keys(RECALL_ASYNC_TRANSCRIPT_PROVIDERS).sort(),
);
});
});

View File

@@ -0,0 +1,100 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_PATH, payload);
}
function removeConfig() {
fs.rmSync(CONFIG_PATH, { force: true });
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
try {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
} catch (error) {
console.warn(
`App uninstall failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
removeConfig();
}
}

View File

@@ -0,0 +1,104 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CallRecording status contract', () => {
it('accepts every app status supported by the current server and every request status value the app mirrors', async () => {
const client = new CoreApiClient();
const serverCallRecordingStatuses = await getServerCallRecordingStatuses();
const created = await client.mutation({
createCallRecording: {
__args: {
data: {
title: 'Integration test recording',
status: CallRecordingStatus.SCHEDULED,
recordingRequestStatus: CallRecordingRequestStatus.REQUESTED,
},
},
id: true,
},
});
const callRecordingId = created.createCallRecording?.id;
expect(callRecordingId).toBeDefined();
if (callRecordingId === undefined) {
throw new Error('Expected call recording creation to return an id');
}
expect(serverCallRecordingStatuses).toEqual(
expect.arrayContaining(Object.values(CallRecordingStatus)),
);
for (const status of Object.values(CallRecordingStatus)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { status } },
status: true,
},
});
expect(updated.updateCallRecording?.status).toBe(status);
}
for (const recordingRequestStatus of Object.values(
CallRecordingRequestStatus,
)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { recordingRequestStatus } },
recordingRequestStatus: true,
},
});
expect(updated.updateCallRecording?.recordingRequestStatus).toBe(
recordingRequestStatus,
);
}
await client.mutation({
destroyCallRecording: {
__args: { id: callRecordingId },
id: true,
},
});
});
});
type GeneratedCoreSchemaRuntime = {
enumCallRecordingStatusEnum: Record<string, string>;
};
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
const generatedCoreSchema =
(await import('twenty-client-sdk/core')) as unknown as GeneratedCoreSchemaRuntime;
return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum);
};

View File

@@ -0,0 +1,15 @@
import { defineAgent } from 'twenty-sdk/define';
import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { DEFAULT_CALL_RECORDING_SUMMARY_PROMPT } from 'src/constants/default-call-recording-summary-prompt';
export default defineAgent({
universalIdentifier: CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
name: 'call-recording-summarizer',
label: 'Call Recording Summarizer',
icon: 'IconFileText',
description:
'Summarizes a meeting transcript into structured Markdown notes stored on the Call Recording.',
prompt: DEFAULT_CALL_RECORDING_SUMMARY_PROMPT,
responseFormat: { type: 'text' },
});

View File

@@ -0,0 +1,224 @@
import { defineApplication, FieldType } from 'twenty-sdk/define';
import { APP_DESCRIPTION } from 'src/constants/app-description';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS } from 'src/constants/call-recorder-transcript-provider-options';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-calendar-bot-scheduling-enabled-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-enabled-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH } from 'src/logic-functions/constants/call-recorder-recording-notice-max-length';
import { CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-message-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
import { DEFAULT_CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED } from 'src/logic-functions/constants/default-call-recorder-calendar-bot-scheduling-enabled';
import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_NOTICE_ENABLED } from 'src/logic-functions/constants/default-call-recorder-recording-notice-enabled';
import { DEFAULT_CALL_RECORDER_RECORDING_NOTICE_MESSAGE } from 'src/logic-functions/constants/default-call-recorder-recording-notice-message';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
import { DEFAULT_CALL_RECORDER_TRANSCRIPT_PROVIDER } from 'src/logic-functions/constants/default-call-recorder-transcript-provider';
import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logo: 'public/logo.svg',
category: 'Productivity',
author: 'Twenty',
galleryImages: ['public/gallery/call-recorder-cover.png'],
applicationVariables: {
[CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Record my calendar meetings',
description:
'Whether the recorder is scheduled for every upcoming calendar meeting with a supported video link. Set to false to stop scheduling bots and cancel the ones already scheduled.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED,
},
[CALL_RECORDER_NAME_ENV_VAR_NAME]: {
universalIdentifier: CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Recorder name',
description: 'Display name the call recorder uses when it joins a call.',
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_NAME,
},
[CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Send recording notice',
description:
'Whether the bot sends a recording notice in supported meeting chats when it joins.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_RECORDING_NOTICE_ENABLED,
},
[CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Recording notice message',
description: `Message sent in supported meeting chats. Limited to ${CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH} characters for Google Meet compatibility; longer values are truncated.`,
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_RECORDING_NOTICE_MESSAGE,
},
[CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Join early (minutes)',
description:
'How many minutes before the meeting start time the bot should join. Set to 0 to join at the scheduled start time.',
isSecret: false,
type: FieldType.NUMBER,
value: DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES,
},
[CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Waiting room timeout (seconds)',
description:
'How many seconds the bot waits in a meeting lobby before giving up and leaving.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS,
},
[CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'No-one joined timeout (seconds)',
description:
'How many seconds the bot stays in an empty meeting when no one else ever joins.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS,
},
[CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Everyone left timeout (seconds)',
description:
'How many seconds the bot keeps recording after everyone else leaves the meeting.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS,
},
[CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Transcript provider',
description:
'Speech-to-text provider used to transcribe recordings once the call ends. Recall.ai transcription needs no extra setup. Gladia re-detects the spoken language per utterance, which transcribes mixed-language calls more accurately, but requires a Gladia API key in the Recall.ai dashboard (Transcription > Gladia) for each region in use.',
isSecret: false,
type: FieldType.SELECT,
options: CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS,
value: DEFAULT_CALL_RECORDER_TRANSCRIPT_PROVIDER,
},
[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'AI summaries',
description:
'Whether AI summaries are generated for call recordings. Set to false to disable and avoid AI credit usage.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_SUMMARY_ENABLED,
},
[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Additional summary prompt',
description:
'Extra instructions appended to the built-in summary prompt (tone, language, focus areas). Leave empty to use the built-in prompt alone.',
isSecret: false,
type: FieldType.RICH_TEXT,
},
[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Use workspace logo',
description:
'Whether the bot displays the workspace logo on its camera tile while in a call. Set to false to disable.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO,
},
[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Bot camera background color',
description:
'Hex color (e.g. #ffffff) drawn behind the workspace logo on the bot camera tile. Defaults to dark gray when unset or invalid.',
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND,
},
},
serverVariables: {
[RECALL_API_KEY_ENV_VAR_NAME]: {
description:
'Recall.ai API key for the configured region. Set by the server admin on this registration after installation; used to create, update, and cancel scheduled recording bots.',
isSecret: true,
isRequired: true,
type: FieldType.TEXT,
},
[RECALL_REGION_ENV_VAR_NAME]: {
description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`,
isSecret: false,
type: FieldType.SELECT,
options: [
{ label: 'US West (us-west-2)', value: 'us-west-2' },
{ label: 'US East (us-east-1)', value: 'us-east-1' },
{ label: 'EU Frankfurt (eu-central-1)', value: 'eu-central-1' },
{ label: 'Asia Tokyo (ap-northeast-1)', value: 'ap-northeast-1' },
],
},
[CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]: {
description: `How many hours Recall.ai retains recording media after processing. Defaults to ${DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS} hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above 168 hours may incur Recall.ai storage charges.`,
isSecret: false,
type: FieldType.NUMBER,
},
[RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: {
description:
'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.',
isSecret: true,
isRequired: true,
type: FieldType.TEXT,
},
},
});

View File

@@ -0,0 +1,22 @@
import {
defineCommandMenuItem,
isSelectAll,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
label: 'Generate call summary',
availabilityType: 'RECORD_SELECTION',
conditionalAvailabilityExpression: !isSelectAll,
});

View File

@@ -0,0 +1,21 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import { describe, expect, it } from 'vitest';
import {
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// This test is nothing more than a sanity check to ensure that the universal identifiers for the call recording media fields are correct.
describe('call recording field universal identifiers', () => {
it('matches the standard CallRecording media field identifiers', () => {
expect(CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER).toBe(
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.audio
.universalIdentifier,
);
expect(CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER).toBe(
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.video
.universalIdentifier,
);
});
});

View File

@@ -0,0 +1,2 @@
export const APP_DESCRIPTION =
'Capture every customer conversation automatically. A call recorder joins eligible meetings and records calls for you.';

View File

@@ -0,0 +1 @@
export const APP_DISPLAY_NAME = 'Call Recorder';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_PREFERENCE_OFF_OPTION_ID =
'cc7de62a-08b6-46c8-aa69-f8117e7dd722';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_PREFERENCE_ON_OPTION_ID =
'72431216-49c4-47c8-99af-de4c3831b0be';

View File

@@ -0,0 +1,4 @@
export enum CallRecorderPreference {
ON = 'ON',
OFF = 'OFF',
}

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'0bb12b64-9fa0-48e5-afc4-887c5224ca13';

View File

@@ -0,0 +1,4 @@
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS = [
{ label: 'Recall.ai', value: 'recallai_async' },
{ label: 'Gladia (multilingual)', value: 'gladia_v2_async' },
];

View File

@@ -0,0 +1 @@
export const CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX = 'SUMMARY_UNAVAILABLE:';

View File

@@ -0,0 +1,58 @@
import { CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX } from 'src/constants/call-recording-summary-unavailable-prefix';
export const DEFAULT_CALL_RECORDING_SUMMARY_PROMPT = [
'You are an AI meeting note-taker for a CRM. You receive the diarized,',
'time-stamped transcript of a recorded call and produce clean, factual',
'meeting notes for the rep who attended.',
'',
'Output GitHub-flavored Markdown only — no preamble, no closing remarks, no',
'surrounding code fences. Emit exactly these sections, in this order, each',
'with its heading exactly as written. Omit a section only when its rule says',
'to.',
'',
'## Gist',
'A single sentence (no bullet) capturing the essence of the meeting.',
'',
'## Overview',
'One paragraph of 2-4 sentences covering what the meeting was about and how',
'it ended. No bullets.',
'',
'## Action Items',
'Concrete follow-ups, grouped by the person responsible. Write each owner as',
'a bold line, then their tasks as a bulleted list beneath it. Use the',
'speaker name from the transcript, or "Unassigned" when no owner is clear.',
'Example:',
'**Alex**',
'- Send the pricing deck',
'- Follow up on Friday',
'If the call produced no follow-ups, write the single line: None.',
'',
'## Notes',
'The discussion split into short topical sections in chronological order.',
'Begin each section with a bold title and the [mm:ss] timestamp it started',
'at, then summarize it as concise bullets. Example:',
'**Introductions** — 0:00',
'- ...',
'**Pricing discussion** — 4:12',
'- ...',
'',
'## Keywords',
'A single comma-separated line of the most important terms, names, and',
'topics from the call.',
'',
'Grounding rules: use only information present in the transcript. Never',
'invent names, numbers, commitments, outcomes, or times — reuse the [mm:ss]',
'timestamps exactly as they appear in the transcript.',
'',
'If the transcript is too short, unintelligible, or contains no substantive',
'discussion, ignore the output format above and return exactly one line in',
'this format:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} <one short sentence explaining why the transcript cannot be summarized reliably>`,
'The reason must describe only what is observable in the transcript. Be',
'specific and do not speculate. Example:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} The transcript contains only greetings and audio checks, with no substantive discussion.`,
'',
'The message may include additional instructions from the workspace admin.',
'Apply them on top of these rules without breaking the output format or the',
'grounding rules above.',
].join('\n');

View File

@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH =
'/call-recorder/generate-call-recording-summaries';

View File

@@ -0,0 +1,2 @@
export const SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH =
'/call-recorder/sync-calendar-bot-scheduling';

View File

@@ -0,0 +1,131 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'8da4b8b5-5edf-4880-b51f-ab6e679ec617';
export const BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'ca1d0179-f611-46bd-ba8a-b503ea9d024e';
export const BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER =
'6f00ea4d-1c5a-47ac-8c18-baacf5ee7c3f';
export const CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'1f28c477-6423-4911-85bf-2296ef112be9';
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'587ead10-c514-468c-944f-17050c88dcc1';
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fd0d9495-29ae-443e-84f9-4ec4d1f2d16a';
export const CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'e1cf956b-356f-468d-b5d9-044ed5fd9e5b';
export const CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'c866ddd4-fb7b-4cb4-8ad1-5599755e495c';
export const CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'33a577e4-02f5-48bd-8bed-d365949caa72';
export const CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'0568ebb2-3f64-47de-8c0d-d367dfbb7462';
export const CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'c54cbacd-ad10-40b4-9056-7aaf23846d64';
export const CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'241180e8-d864-4160-ad02-db44a9e8d395';
export const CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'8ee9444a-2437-4def-8e61-6e493862a4fd';
export const CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fbf5f1ef-34c7-4411-b392-e314f6d064d0';
export const CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'7103451a-2c46-4c16-bc2c-a9f28b329ee5';
export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'1d7bf052-4338-44da-b373-23f6d291a50c';
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'598c1003-2767-4ccf-bc95-c251e6b92189';
export const CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fc06a41c-d1f3-4c51-a64b-d71ed77374c1';
export const CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'12e4e14d-d539-4d07-b477-4773539dd20b';
export const CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bf8ebf7e-4d52-4a7b-8d41-337c53d478ab';
export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER =
'2eafc2d0-8fec-430c-a939-65ca5fbc0f08';
export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
'388543f3-ec85-4905-ad7d-99801337ed5c';
export const CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'804ca433-2ca9-4ead-b1b5-ed2d5d4a4804';
export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER =
'bb9523d3-457e-4f4b-8c79-27a77afb87da';
export const CANCEL_SCHEDULED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'a1b0f097-edeb-45a3-a53e-bf666cbfe3a4';
export const CANCEL_RECALL_BOT_ON_CALL_RECORDING_DELETE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'84981386-881f-4123-a2dd-82e0932ba663';
export const CANCEL_RECALL_BOTS_ON_UNINSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'8f00ec95-38f0-4dc7-816a-78374affa32a';
export const CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'fa394597-dc73-4fee-9758-dea7401b0b8f';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'5fcf4d3a-0aca-42d9-9beb-7387f43ec180';
export const ENQUEUE_ORPHANED_RECALL_BOT_CLEANUP_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'455458ce-bbc3-4b39-a3f2-950540c9eda7';
export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'3467c68d-5d81-4f16-8234-b4182cb17f19';
export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'6b7b0989-2946-413b-9b6a-08336b418788';
export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'4b81fd8f-5626-4d5a-a97e-0543436bb555';
export const IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'6b975429-7f0d-4a08-8e5e-5a830e6dc621';
export const PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd7d1170f-abb1-4c9b-8258-13219a611b03';
export const PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'13d9c427-447e-494a-8d3c-1af5d0bacb82';
export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'9215afe6-1497-4149-a49d-e608e239bbaf';
export const RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'06fafff7-c722-41d1-869d-9554736f4c53';
export const SCHEDULE_RECALL_BOT_ON_CALL_RECORDING_UPDATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'fad4b1b5-8b6e-48c7-a8c7-3aecae2672c1';
export const STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e362aa9b-52c6-4b7e-bb20-927e0e8d7cbe';
export const START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'c7a59e9c-935d-45ae-ae95-ded8d327f581';
export const SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'05ab984e-c6ad-4b1c-800f-8f029dc7aac8';
export const SYNC_CALENDAR_BOT_SCHEDULING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'1ec45adf-8521-4a53-833f-3b9bc20f0e9d';

View File

@@ -0,0 +1,75 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineApplicationRole,
} from 'twenty-sdk/define';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts and summaries.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.AI,
// The settings front component reads and writes the app's own application
// variables through the metadata API, which is gated behind this flag.
SystemPermissionFlag.APPLICATIONS,
],
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'botScheduleAttemptedAt',
label: 'Bot Schedule Attempted At',
description:
'Set right before a Recall bot creation request is sent; recovery uses it to tell rows that never reached Recall from rows whose creation outcome is unknown.',
icon: 'IconClockPlay',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.TEXT,
name: 'botScheduleIdempotencyKey',
label: 'Bot Schedule Idempotency Key',
description:
'Idempotency key of the last Recall bot creation attempt; when the scheduling inputs still hash to this key, recovery can safely re-send the creation instead of listing bots.',
icon: 'IconKey',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.TEXT,
name: 'callRecorderFailureReason',
label: 'Call Recorder Failure Reason',
description:
'Provider-specific reason the call recorder could not produce a recording.',
icon: 'IconAlertTriangle',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,40 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CallRecorderPreference } from 'src/constants/call-recorder-preference';
import { CALL_RECORDER_PREFERENCE_OFF_OPTION_ID } from 'src/constants/call-recorder-preference-off-option-id';
import { CALL_RECORDER_PREFERENCE_ON_OPTION_ID } from 'src/constants/call-recorder-preference-on-option-id';
import { CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.SELECT,
name: 'callRecorderPreference',
label: 'Recording Bot',
description:
'Set to On by the app when it schedules the recorder for this meeting. Turn it off to skip this event.',
icon: 'IconRobot',
isNullable: true,
options: [
{
id: CALL_RECORDER_PREFERENCE_ON_OPTION_ID,
value: CallRecorderPreference.ON,
label: 'On',
position: 0,
color: 'green',
},
{
id: CALL_RECORDER_PREFERENCE_OFF_OPTION_ID,
value: CallRecorderPreference.OFF,
label: 'Off',
position: 1,
color: 'red',
},
],
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'artifactsImportClaimedAt',
label: 'Artifacts Import Claimed At',
description:
'Lease held by the worker importing this recording’s artifacts; prevents concurrent webhook retries from duplicating provider imports.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'transcriptImportClaimedAt',
label: 'Transcript Import Claimed At',
description:
'Lease held by the worker importing this recording’s transcript; prevents concurrent webhook retries from requesting the transcript twice.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledAdornmentContainer = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-bottom-left-radius: ${() => themeCssVariables.border.radius.md};
border-right: none;
border-top-left-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
height: 32px;
padding: ${() => themeCssVariables.spacing[2]};
`;
const StyledAdornedInput = styled(StyledSettingsTextInput)`
border-bottom-left-radius: 0;
border-left: none;
border-top-left-radius: 0;
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
width: 100%;
&:focus-within ${StyledAdornmentContainer} {
border-color: ${() => themeCssVariables.color.blue};
}
`;
type AdornedHexInputProps = {
id: string;
value: string;
swatchColor: string;
onChange: (value: string) => void;
onBlur?: () => void;
};
export const AdornedHexInput = ({
id,
value,
swatchColor,
onChange,
onBlur,
}: AdornedHexInputProps) => (
<StyledContainer>
<StyledAdornmentContainer>
<SettingsColorSample colorName="gray" color={swatchColor} />
</StyledAdornmentContainer>
<StyledAdornedInput
id={id}
type="text"
autoComplete="off"
placeholder="#1d1d1d"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
/>
</StyledContainer>
);

View File

@@ -0,0 +1,80 @@
import 'twenty-ui/style.css';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
useColorScheme,
useFrontComponentId,
} from 'twenty-sdk/front-component';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import {
ThemeContext,
themeCssVariables,
type ThemeType,
} from 'twenty-ui/theme-constants';
import { InCallSection } from 'src/front-components/components/InCallSection';
import { RecorderSection } from 'src/front-components/components/RecorderSection';
import { SchedulingSection } from 'src/front-components/components/SchedulingSection';
import { TranscriptionSection } from 'src/front-components/components/TranscriptionSection';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledPausedNotice = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-size: ${() => themeCssVariables.font.size.md};
line-height: ${() => themeCssVariables.text.lineHeight.md};
`;
export const CallRecorderSettings = () => {
const colorScheme = useColorScheme();
const frontComponentId = useFrontComponentId();
const [isSchedulingEnabled, setIsSchedulingEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
) !== 'false',
);
// twenty-ui components read icon sizes off ThemeContext, and the context
// default resolves them to var() strings an SVG size attribute cannot use —
// menu check marks render at their intrinsic size without a real theme here.
return (
<ThemeContext.Provider
value={{
theme: (colorScheme === 'dark'
? THEME_DARK
: THEME_LIGHT) as unknown as ThemeType,
colorScheme,
}}
>
<StyledContainer>
<SchedulingSection
frontComponentId={frontComponentId}
isEnabled={isSchedulingEnabled}
onEnabledChange={setIsSchedulingEnabled}
/>
{isSchedulingEnabled ? (
<>
<RecorderSection frontComponentId={frontComponentId} />
<InCallSection frontComponentId={frontComponentId} />
<TranscriptionSection frontComponentId={frontComponentId} />
</>
) : (
<StyledPausedNotice>
Recording is paused. Turn it back on to schedule recorders and edit
how they behave. Your settings are kept.
</StyledPausedNotice>
)}
</StyledContainer>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,43 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledExternalContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: fit-content;
min-height: 0;
padding: ${() => themeCssVariables.spacing[1]};
width: 100%;
`;
const StyledScrollableContainer = styled.div`
box-sizing: border-box;
display: flex;
min-height: 0;
overflow-y: auto;
scrollbar-color: ${() => themeCssVariables.border.color.medium} transparent;
scrollbar-width: thin;
width: 100%;
`;
const StyledInternalContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
}: {
children: ReactNode;
}) => (
<StyledExternalContainer>
<StyledScrollableContainer>
<StyledInternalContainer>{children}</StyledInternalContainer>
</StyledScrollableContainer>
</StyledExternalContainer>
);

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import {
MenuItemLeftContent,
StyledMenuItemIconCheck,
StyledMenuItemSelect,
} from 'twenty-ui/navigation';
import { useTheme } from 'twenty-ui/theme-constants';
const StyledOptionContainer = styled.div`
cursor: pointer;
width: 100%;
`;
type DropdownMenuOptionProps = {
id: string;
text: string;
selected: boolean;
isActive: boolean;
LeftComponent?: ReactNode;
onSelect: () => void;
};
export const DropdownMenuOption = ({
id,
text,
selected,
isActive,
LeftComponent,
onSelect,
}: DropdownMenuOptionProps) => {
const theme = useTheme();
return (
<StyledOptionContainer
id={id}
role="option"
tabIndex={-1}
aria-selected={selected}
onClick={onSelect}
>
<StyledMenuItemSelect focused={isActive}>
<MenuItemLeftContent
LeftComponent={LeftComponent}
LeftIcon={null}
text={text}
/>
{selected && <StyledMenuItemIconCheck size={theme.icon.size.md} />}
</StyledMenuItemSelect>
</StyledOptionContainer>
);
};

View File

@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledDropdownMenuSeparator = styled.div`
background-color: ${() => themeCssVariables.border.color.light};
min-height: 1px;
width: 100%;
`;
export const DropdownMenuSeparator = () => <StyledDropdownMenuSeparator />;

View File

@@ -0,0 +1,113 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TimingCounterRow } from 'src/front-components/components/TimingCounterRow';
import {
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD,
CALL_RECORDER_RECORDING_NOTICE_ROW,
CALL_RECORDER_TIMING_ROWS,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { truncateRecordingNoticeMessage } from 'src/logic-functions/utils/truncate-recording-notice-message.util';
type InCallSectionProps = {
frontComponentId: string;
};
export const InCallSection = ({ frontComponentId }: InCallSectionProps) => {
const noticeMessageInputId = useId();
const [isNoticeEnabled, setIsNoticeEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
) === 'true',
);
const [noticeMessageValue, setNoticeMessageValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
),
);
const { saveImmediately: saveNoticeEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
});
const { saveDebounced: saveNoticeMessageDebounced } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
});
const handleNoticeEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsNoticeEnabled(checked);
saveNoticeEnabledImmediately(value);
};
const handleNoticeMessageChange = (value: string) => {
const truncatedValue = truncateRecordingNoticeMessage(value);
setNoticeMessageValue(truncatedValue);
saveNoticeMessageDebounced(truncatedValue);
};
return (
<Section>
<H2Title
title="In the call"
description="What the recorder does while it is in your meetings."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
{CALL_RECORDER_TIMING_ROWS.map((row) => (
<TimingCounterRow
key={row.variableKey}
frontComponentId={frontComponentId}
variableKey={row.variableKey}
title={row.title}
description={row.description}
Icon={row.Icon}
divider
persistedValue={getApplicationVariableValue(row.variableKey)}
/>
))}
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_RECORDING_NOTICE_ROW.Icon}
title={CALL_RECORDER_RECORDING_NOTICE_ROW.title}
description={CALL_RECORDER_RECORDING_NOTICE_ROW.description}
checked={isNoticeEnabled}
onChange={handleNoticeEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isNoticeEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.label}
inputId={noticeMessageInputId}
hint={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.hint}
>
<StyledSettingsTextArea
id={noticeMessageInputId}
placeholder="Value"
disabled={!isNoticeEnabled}
value={noticeMessageValue}
onChange={(event) =>
handleNoticeMessageChange(event.target.value)
}
onBlur={() => saveNoticeMessageDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
const StyledField = styled.div`
display: flex;
flex-direction: column;
`;
// twenty-front hard-codes this size in FieldLabel.module.scss; there is no token.
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: 11px;
font-weight: ${() => themeCssVariables.font.weight.semiBold};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledHint = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
margin-top: ${() => themeCssVariables.spacing[3]};
`;
type LabelledSettingsFieldProps = {
label: string;
inputId: string;
hint?: string;
errorMessage?: string;
children: ReactNode;
};
export const LabelledSettingsField = ({
label,
inputId,
hint,
errorMessage,
children,
}: LabelledSettingsFieldProps) => (
<StyledField>
<StyledLabel htmlFor={inputId}>{label}</StyledLabel>
{children}
{isNonEmptyString(errorMessage) && (
<StyledSettingsError>{errorMessage}</StyledSettingsError>
)}
{isNonEmptyString(hint) && <StyledHint>{hint}</StyledHint>}
</StyledField>
);

View File

@@ -0,0 +1,183 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import { H2Title } from 'twenty-ui/typography';
import { AdornedHexInput } from 'src/front-components/components/AdornedHexInput';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { TileBackgroundControl } from 'src/front-components/components/TileBackgroundControl';
import {
CALL_RECORDER_NAME_FIELD,
CALL_RECORDER_TILE_BACKGROUND_ROW,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
type RecorderSectionProps = {
frontComponentId: string;
};
export const RecorderSection = ({ frontComponentId }: RecorderSectionProps) => {
const nameInputId = useId();
const hexInputId = useId();
const [nameValue, setNameValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_NAME_FIELD.variableKey),
);
const [isWorkspaceLogoEnabled, setIsWorkspaceLogoEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
) === 'true',
);
const [tileBackgroundValue, setTileBackgroundValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey),
);
const [isCustomHexOverride, setIsCustomHexOverride] = useState<
boolean | undefined
>(undefined);
const { saveDebounced: saveNameDebounced } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_NAME_FIELD.variableKey,
});
const { saveImmediately: saveWorkspaceLogoImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
});
const {
saveDebounced: saveTileBackgroundDebounced,
saveImmediately: saveTileBackgroundImmediately,
} = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
});
const selectedTileColor = getThemeColorFromHex(tileBackgroundValue);
const isCustomHexSelected =
isCustomHexOverride ?? isUndefined(selectedTileColor);
const swatchColor =
normalizeHexColor(tileBackgroundValue) ??
DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND;
const handleNameChange = (value: string) => {
setNameValue(value);
saveNameDebounced(value);
};
const handleWorkspaceLogoChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsWorkspaceLogoEnabled(checked);
saveWorkspaceLogoImmediately(value);
};
const handleSelectTileColor = (color: ThemeColor) => {
const value = THEME_COLOR_HEX[color];
setIsCustomHexOverride(false);
setTileBackgroundValue(value);
saveTileBackgroundImmediately(value);
};
const handleHexChange = (value: string) => {
setTileBackgroundValue(value);
const valueToSave = getNormalizedHexValue(value);
if (isUndefined(valueToSave)) {
saveTileBackgroundDebounced.cancel();
return;
}
saveTileBackgroundDebounced(valueToSave);
};
return (
<Section>
<H2Title
title="Recorder"
description="How the recorder appears when it joins your meetings."
/>
<StyledSettingsSectionStack>
<LabelledSettingsField
label={CALL_RECORDER_NAME_FIELD.label}
inputId={nameInputId}
hint={CALL_RECORDER_NAME_FIELD.hint}
>
<StyledSettingsTextInput
id={nameInputId}
type="text"
autoComplete="off"
placeholder="Value"
value={nameValue}
onChange={(event) => handleNameChange(event.target.value)}
onBlur={() => saveNameDebounced.flush()}
/>
</LabelledSettingsField>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.Icon}
title={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.title}
description={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.description}
divider
checked={isWorkspaceLogoEnabled}
onChange={handleWorkspaceLogoChange}
/>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TILE_BACKGROUND_ROW.Icon}
title={CALL_RECORDER_TILE_BACKGROUND_ROW.title}
description={CALL_RECORDER_TILE_BACKGROUND_ROW.description}
isDimmed={!isWorkspaceLogoEnabled}
>
<TileBackgroundControl
swatchColor={swatchColor}
selectedColor={
isCustomHexSelected ? undefined : selectedTileColor
}
isCustomSelected={isCustomHexSelected}
disabled={!isWorkspaceLogoEnabled}
onSelectColor={handleSelectTileColor}
onSelectCustom={() => setIsCustomHexOverride(true)}
/>
</SettingsOptionCardContentSelect>
</StyledSettingsCard>
{isCustomHexSelected && (
<StyledDimmable $dimmed={!isWorkspaceLogoEnabled}>
<LabelledSettingsField
label="Custom hex"
inputId={hexInputId}
hint="Six-digit hex, for example #1d1d1d."
errorMessage={
isUndefined(getNormalizedHexValue(tileBackgroundValue))
? 'Invalid hex colour'
: undefined
}
>
<AdornedHexInput
id={hexInputId}
value={tileBackgroundValue}
swatchColor={swatchColor}
onChange={handleHexChange}
onBlur={() => saveTileBackgroundDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,45 @@
import { Section } from 'twenty-ui/layout';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { requestCalendarBotSchedulingSync } from 'src/front-components/utils/request-calendar-bot-scheduling-sync.util';
type SchedulingSectionProps = {
frontComponentId: string;
isEnabled: boolean;
onEnabledChange: (isEnabled: boolean) => void;
};
export const SchedulingSection = ({
frontComponentId,
isEnabled,
onEnabledChange,
}: SchedulingSectionProps) => {
const { saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
onSaveSuccess: () => requestCalendarBotSchedulingSync(),
onSaveError: (value) => onEnabledChange(value !== 'true'),
});
const handleChange = (checked: boolean) => {
onEnabledChange(checked);
saveImmediately(checked ? 'true' : 'false');
};
return (
<Section>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.Icon}
title={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.title}
description={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.description}
checked={isEnabled}
onChange={handleChange}
/>
</StyledSettingsCard>
</Section>
);
};

View File

@@ -0,0 +1,14 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHr = styled.hr`
border: none;
border-top: 1px solid ${() => themeCssVariables.border.color.light};
margin: 0px;
margin-left: ${() => themeCssVariables.spacing[4]};
margin-right: ${() => themeCssVariables.spacing[4]};
`;
export const Separator = () => {
return <StyledHr />;
};

View File

@@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsCardContent = styled.div<{ $isDimmed?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
opacity: ${({ $isDimmed }) => ($isDimmed ? 0.72 : 1)};
padding: ${() => themeCssVariables.spacing[4]};
`;
export const StyledSettingsCardIcon = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.primary};
border: 2px solid ${() => themeCssVariables.border.color.light};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
height: ${() => themeCssVariables.spacing[8]};
justify-content: center;
min-width: ${() => themeCssVariables.spacing[8]};
width: ${() => themeCssVariables.spacing[8]};
`;
export const StyledSettingsCardTitle = styled.div`
color: ${() => themeCssVariables.font.color.primary};
font-weight: ${() => themeCssVariables.font.weight.medium};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
export const StyledSettingsCardTextContainer = styled.div`
flex: 1;
min-width: 0;
`;
export const StyledSettingsCardDescription = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
overflow: hidden;
`;

View File

@@ -0,0 +1,21 @@
import styled from '@emotion/styled';
import { ColorSample, type ColorSampleProps } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsColorSampleProps = Omit<ColorSampleProps, 'variant'>;
// The front-component style bridge does not preserve ColorSample's pill radius.
const StyledContainer = styled.span`
display: flex;
> div {
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
}
`;
export const SettingsColorSample = (props: SettingsColorSampleProps) => (
<StyledContainer>
<ColorSample {...props} />
</StyledContainer>
);

View File

@@ -0,0 +1,147 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { IconMinus, IconPlus } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledCounterContainer = styled.div<{ $showButtons: boolean }>`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
margin-left: auto;
width: ${({ $showButtons }) =>
$showButtons
? themeCssVariables.spacing[30]
: themeCssVariables.spacing[16]};
button svg {
pointer-events: none;
}
`;
const StyledTextInputContainer = styled.div`
width: ${() => themeCssVariables.spacing[16]};
input {
font-weight: ${() => themeCssVariables.font.weight.medium};
height: ${() => themeCssVariables.spacing[6]};
text-align: center;
width: ${() => themeCssVariables.spacing[16]};
}
`;
type SettingsCounterProps = {
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
disabled?: boolean;
showButtons?: boolean;
inputId?: string;
errorMessageId?: string;
isInvalid?: boolean;
};
// twenty-front's SettingsCounter works on numbers and snaps invalid text back
// to minValue. The app keeps its own draft-string validation instead, so a
// half-typed value stays on screen with the Invalid number line beneath it.
export const SettingsCounter = ({
value,
onChange,
onBlur,
minValue = 0,
maxValue,
disabled = false,
showButtons = true,
inputId,
errorMessageId,
isInvalid = false,
}: SettingsCounterProps) => {
const parsedValue = Number(value.trim());
const isParsable = value.trim() !== '' && Number.isFinite(parsedValue);
const handleDecrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = Math.max(parsedValue - 1, minValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleIncrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = isUndefined(maxValue)
? parsedValue + 1
: Math.min(parsedValue + 1, maxValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleTextInputChange = (nextValue: string) => {
const nextParsedValue = Number(nextValue.trim());
if (
nextValue.trim() !== '' &&
Number.isFinite(nextParsedValue) &&
(nextParsedValue < minValue ||
(!isUndefined(maxValue) && nextParsedValue > maxValue))
) {
return;
}
onChange(nextValue, 'input');
};
return (
<StyledCounterContainer $showButtons={showButtons}>
{showButtons && (
<IconButton
size="small"
Icon={IconMinus}
variant="secondary"
onClick={handleDecrementCounter}
disabled={disabled}
ariaLabel="Decrease"
/>
)}
<StyledTextInputContainer>
<StyledSettingsTextInput
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
value={value}
disabled={disabled}
aria-describedby={errorMessageId}
aria-invalid={isInvalid}
onChange={(event) => handleTextInputChange(event.target.value)}
onBlur={onBlur}
/>
</StyledTextInputContainer>
{showButtons && (
<IconButton
size="small"
Icon={IconPlus}
variant="secondary"
onClick={handleIncrementCounter}
disabled={disabled}
ariaLabel="Increase"
/>
)}
</StyledCounterContainer>
);
};

View File

@@ -0,0 +1,87 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsCounter } from 'src/front-components/components/SettingsCounter';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
type SettingsOptionCardContentCounterProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
errorMessage?: string;
inputId?: string;
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
};
export const SettingsOptionCardContentCounter = ({
Icon,
title,
description,
divider,
disabled = false,
errorMessage,
inputId,
value,
onChange,
onBlur,
minValue,
maxValue,
}: SettingsOptionCardContentCounterProps) => {
const hasError = isNonEmptyString(errorMessage);
const errorMessageId = hasError && inputId ? `${inputId}-error` : undefined;
return (
<>
<StyledSettingsCardContent>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={inputId}>{title}</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
{hasError && (
<StyledSettingsError id={errorMessageId} role="alert">
{errorMessage}
</StyledSettingsError>
)}
</StyledSettingsCardTextContainer>
<SettingsCounter
inputId={inputId}
value={value}
onChange={onChange}
onBlur={onBlur}
minValue={minValue}
maxValue={maxValue}
disabled={disabled}
errorMessageId={errorMessageId}
isInvalid={hasError}
/>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,59 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
const StyledSelectContainer = styled.div`
flex-shrink: 0;
justify-content: flex-end;
margin-left: auto;
max-width: ${() => themeCssVariables.spacing[30]};
`;
type SettingsOptionCardContentSelectProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
isDimmed?: boolean;
children?: React.ReactNode;
};
export const SettingsOptionCardContentSelect = ({
Icon,
title,
description,
divider,
isDimmed = false,
children,
}: SettingsOptionCardContentSelectProps) => (
<>
<StyledSettingsCardContent $isDimmed={isDimmed}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSelectContainer>{children}</StyledSelectContainer>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);

View File

@@ -0,0 +1,100 @@
import styled from '@emotion/styled';
import { useId } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { Toggle } from 'src/front-components/components/Toggle';
const StyledSettingsCardToggleContent = styled.div<{ $disabled?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
padding: ${() => themeCssVariables.spacing[4]};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
position: relative;
&:hover {
background: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledSettingsCardToggleButtonContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
margin-left: auto;
`;
const StyledSettingsCardToggleCover = styled.span`
cursor: pointer;
inset: 0;
position: absolute;
`;
type SettingsOptionCardContentToggleProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
};
export const SettingsOptionCardContentToggle = ({
Icon,
title,
description,
divider,
disabled = false,
checked,
onChange,
}: SettingsOptionCardContentToggleProps) => {
const toggleId = useId();
return (
<>
<StyledSettingsCardToggleContent $disabled={disabled}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={toggleId}>
{title}
<StyledSettingsCardToggleCover />
</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSettingsCardToggleButtonContainer>
<Toggle
id={toggleId}
checked={checked}
disabled={disabled}
toggleSize="small"
onChange={onChange}
/>
</StyledSettingsCardToggleButtonContainer>
</StyledSettingsCardToggleContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,33 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-front reads these off ThemeContext, but a front component has no
// ThemeProvider: the context default resolves every value to a var() string,
// which an SVG size attribute cannot use. Sizes come from the numeric ICON
// constants and the colour is inherited through currentColor instead.
const StyledIconCustomizer = styled.div<{ $zoom: number; $rotate: number }>`
align-items: center;
color: ${() => themeCssVariables.IllustrationIcon.color.gray};
display: inline-flex;
justify-content: center;
pointer-events: none;
transform: ${({ $zoom, $rotate }) => `scale(${$zoom}) rotate(${$rotate}deg)`};
`;
type SettingsOptionIconCustomizerProps = {
Icon: IconComponent;
zoom?: number;
rotate?: number;
};
export const SettingsOptionIconCustomizer = ({
Icon,
zoom = 1,
rotate = -4,
}: SettingsOptionIconCustomizerProps) => (
<StyledIconCustomizer $zoom={zoom} $rotate={rotate}>
<Icon size={ICON.size.lg} stroke={ICON.stroke.md} />
</StyledIconCustomizer>
);

View File

@@ -0,0 +1,144 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { type KeyboardEvent, type ReactNode, useRef } from 'react';
import { IconChevronDown } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type SettingsSelectNavigationKey } from 'src/front-components/utils/get-next-active-option-index.util';
const StyledControlContainer = styled.button<{
$hasAdornment: boolean;
}>`
appearance: none;
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
cursor: pointer;
display: grid;
font: inherit;
gap: ${() => themeCssVariables.spacing[1]};
grid-template-columns: ${({ $hasAdornment }) =>
$hasAdornment ? 'auto 1fr auto' : '1fr auto'};
height: ${() => themeCssVariables.spacing[6]};
max-width: 100%;
padding: 0 ${() => themeCssVariables.spacing[2]};
text-align: left;
&:disabled {
color: ${() => themeCssVariables.font.color.secondary};
cursor: not-allowed;
}
`;
const StyledChevronWrapper = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
pointer-events: none;
`;
type SettingsSelectControlProps = {
label: string;
ariaLabel: string;
listboxId: string;
activeDescendantId: string | undefined;
adornment?: ReactNode;
disabled?: boolean;
isExpanded: boolean;
onNavigate: (key: SettingsSelectNavigationKey) => void;
onSelectActive: () => void;
onEscape: () => void;
onClick: () => void;
};
export const SettingsSelectControl = ({
label,
ariaLabel,
listboxId,
activeDescendantId,
adornment,
disabled = false,
isExpanded,
onNavigate,
onSelectActive,
onEscape,
onClick,
}: SettingsSelectControlProps) => {
const shouldIgnoreNextClickRef = useRef(false);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Tab' && isExpanded) {
onEscape();
return;
}
if (event.key === 'Escape' && isExpanded) {
onEscape();
return;
}
if (!isExpanded && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault();
onClick();
return;
}
if (
isExpanded &&
(event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === 'Home' ||
event.key === 'End')
) {
event.preventDefault();
onNavigate(event.key);
return;
}
if (isExpanded && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
shouldIgnoreNextClickRef.current = true;
onSelectActive();
}
};
const handleClick = () => {
if (shouldIgnoreNextClickRef.current) {
shouldIgnoreNextClickRef.current = false;
return;
}
onClick();
};
return (
<StyledControlContainer
type="button"
role="combobox"
$hasAdornment={!isUndefined(adornment)}
disabled={disabled}
aria-label={`${ariaLabel}: ${label}`}
aria-expanded={isExpanded}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={isExpanded ? activeDescendantId : undefined}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{adornment}
<OverflowingTextWithTooltip text={label} />
<StyledChevronWrapper>
<IconChevronDown size={ICON.size.md} stroke={ICON.stroke.sm} />
</StyledChevronWrapper>
</StyledControlContainer>
);
};

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX,
SETTINGS_SELECT_MENU_GAP_PIXELS,
SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS,
SETTINGS_SELECT_MENU_WIDTH_PIXELS,
SETTINGS_SELECT_MENU_Z_INDEX,
} from 'src/front-components/constants/settings-select-menu.constant';
// The sandbox cannot portal into the host document, so the menu opens inline
// below its control, and the backdrop stands in for the outside click the
// host page never forwards to the front component.
const StyledBackdrop = styled.div`
inset: 0;
position: fixed;
z-index: ${SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX};
`;
const StyledMenu = styled.div`
background-color: ${() => themeCssVariables.background.primary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-shadow: ${() => themeCssVariables.boxShadow.strong};
box-sizing: border-box;
display: flex;
flex-direction: column;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
max-height: ${SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS}px;
overflow: hidden;
position: absolute;
right: 0;
top: calc(100% + ${SETTINGS_SELECT_MENU_GAP_PIXELS}px);
width: ${SETTINGS_SELECT_MENU_WIDTH_PIXELS}px;
z-index: ${SETTINGS_SELECT_MENU_Z_INDEX};
`;
type SettingsSelectMenuProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
export const SettingsSelectMenu = ({
isOpen,
onClose,
children,
}: SettingsSelectMenuProps) => (
<>
<StyledBackdrop
aria-hidden={!isOpen}
style={{ display: isOpen ? 'block' : 'none' }}
onClick={onClose}
/>
<StyledMenu
aria-hidden={!isOpen}
style={{ display: isOpen ? 'flex' : 'none' }}
>
{children}
</StyledMenu>
</>
);

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledDimmable = styled.div<{ $dimmed: boolean }>`
opacity: ${({ $dimmed }) => ($dimmed ? 0.72 : 1)};
`;

View File

@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors twenty-ui's Card without its overflow: hidden, which would clip the
// select menus that open inline below their rows. The rows paint their own
// background, so the first and last ones take over the card's corners.
export const StyledSettingsCard = styled.div`
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.secondary};
width: 100%;
& > :first-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
& > :last-child {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
`;

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsError = styled.div`
color: ${() => themeCssVariables.font.color.danger};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-top: ${() => themeCssVariables.spacing[1]};
`;

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsSectionStack = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledSettingsSelectAnchor = styled.div`
position: relative;
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextArea = styled.textarea`
display: block;
line-height: 1.5;
min-height: 96px;
resize: vertical;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextInput = styled.input`
display: flex;
flex-grow: 1;
height: 32px;
text-overflow: ellipsis;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,60 @@
import styled from '@emotion/styled';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { DropdownMenuSeparator } from 'src/front-components/components/DropdownMenuSeparator';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
const StyledListbox = styled.div`
min-height: 0;
overflow-y: auto;
width: 100%;
`;
type ThemeColorPickerMenuProps = {
listboxId: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
activeOption: ThemeColor | 'custom';
getOptionId: (option: ThemeColor | 'custom') => string;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const ThemeColorPickerMenu = ({
listboxId,
selectedColor,
isCustomSelected,
activeOption,
getOptionId,
onSelectColor,
onSelectCustom,
}: ThemeColorPickerMenuProps) => (
<StyledListbox id={listboxId} role="listbox" aria-label="Tile background">
<DropdownMenuItemsContainer>
{MAIN_COLOR_NAMES.map((colorName) => (
<DropdownMenuOption
key={colorName}
id={getOptionId(colorName)}
onSelect={() => onSelectColor(colorName)}
text={DEFAULT_COLOR_LABELS[colorName]}
selected={colorName === selectedColor}
isActive={colorName === activeOption}
LeftComponent={<SettingsColorSample colorName={colorName} />}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuOption
id={getOptionId('custom')}
text="Custom hex"
selected={isCustomSelected}
isActive={activeOption === 'custom'}
onSelect={onSelectCustom}
/>
</DropdownMenuItemsContainer>
</StyledListbox>
);

View File

@@ -0,0 +1,141 @@
import { isUndefined } from '@sniptt/guards';
import { useState } from 'react';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { isDefined } from 'twenty-ui/utilities';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { ThemeColorPickerMenu } from 'src/front-components/components/ThemeColorPickerMenu';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
// The control is capped at 120px, where "Custom hex" ellipsises to "Custom …".
const CUSTOM_CONTROL_LABEL = 'Custom';
const CUSTOM_OPTION_VALUE = 'custom';
const FALLBACK_SWATCH_COLOR_NAME: ThemeColor = 'gray';
const TILE_BACKGROUND_LISTBOX_ID = 'call-recorder-tile-background';
type TileBackgroundOption = ThemeColor | typeof CUSTOM_OPTION_VALUE;
// Built lazily: the SDK manifest extractor loads this module with twenty-ui
// mocked, so module-scope iteration over MAIN_COLOR_NAMES would throw.
const getTileBackgroundOptions = (): TileBackgroundOption[] => [
...MAIN_COLOR_NAMES,
CUSTOM_OPTION_VALUE,
];
const getTileBackgroundOptionId = (option: TileBackgroundOption) =>
`${TILE_BACKGROUND_LISTBOX_ID}-option-${option}`;
type TileBackgroundControlProps = {
swatchColor: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
disabled: boolean;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const TileBackgroundControl = ({
swatchColor,
selectedColor,
isCustomSelected,
disabled,
onSelectColor,
onSelectCustom,
}: TileBackgroundControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const tileBackgroundOptions = getTileBackgroundOptions();
const label = isUndefined(selectedColor)
? CUSTOM_CONTROL_LABEL
: (DEFAULT_COLOR_LABELS[selectedColor] ?? selectedColor);
const selectedOption: TileBackgroundOption = isCustomSelected
? CUSTOM_OPTION_VALUE
: (selectedColor ?? FALLBACK_SWATCH_COLOR_NAME);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(
Math.max(tileBackgroundOptions.indexOf(selectedOption), 0),
);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: tileBackgroundOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = tileBackgroundOptions[activeOptionIndex];
if (activeOption === CUSTOM_OPTION_VALUE) {
onSelectCustom();
} else if (isDefined(activeOption)) {
onSelectColor(activeOption);
}
handleMenuClose();
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={label}
ariaLabel="Tile background"
disabled={disabled}
listboxId={TILE_BACKGROUND_LISTBOX_ID}
activeDescendantId={getTileBackgroundOptionId(
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE,
)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
adornment={
<SettingsColorSample
colorName={selectedColor ?? FALLBACK_SWATCH_COLOR_NAME}
color={isCustomSelected ? swatchColor : undefined}
/>
}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<ThemeColorPickerMenu
listboxId={TILE_BACKGROUND_LISTBOX_ID}
selectedColor={selectedColor}
isCustomSelected={isCustomSelected}
activeOption={
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE
}
getOptionId={getTileBackgroundOptionId}
onSelectColor={(color) => {
onSelectColor(color);
handleMenuClose();
}}
onSelectCustom={() => {
onSelectCustom();
handleMenuClose();
}}
/>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,70 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { SettingsOptionCardContentCounter } from 'src/front-components/components/SettingsOptionCardContentCounter';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
type TimingCounterRowProps = {
frontComponentId: string;
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
divider: boolean;
persistedValue: string;
};
export const TimingCounterRow = ({
frontComponentId,
variableKey,
title,
description,
Icon,
divider,
persistedValue,
}: TimingCounterRowProps) => {
const inputId = useId();
const [inputValue, setInputValue] = useState(persistedValue);
const { saveDebounced, saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey,
});
const handleChange = (value: string, changeType: 'input' | 'button') => {
setInputValue(value);
const valueToSave = getNormalizedNumberValue(value);
if (isUndefined(valueToSave)) {
saveDebounced.cancel();
return;
}
if (changeType === 'button') {
saveImmediately(valueToSave);
return;
}
saveDebounced(valueToSave);
};
return (
<SettingsOptionCardContentCounter
Icon={Icon}
title={title}
description={description}
divider={divider}
inputId={inputId}
value={inputValue}
errorMessage={
isUndefined(getNormalizedNumberValue(inputValue))
? 'Invalid number'
: undefined
}
onChange={handleChange}
onBlur={() => saveDebounced.flush()}
/>
);
};

View File

@@ -0,0 +1,116 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Toggle is a base-ui Switch whose click handler constructs a
// PointerEvent, which the front component sandbox does not provide. This is
// the same control as a plain button, styled after twenty-ui's Toggle. The
// host theme squircles every corner, so the capsule and thumb opt out.
export type ToggleSize = 'small' | 'medium';
type ToggleDimensions = {
width: number;
height: number;
thumbSize: number;
};
const TOGGLE_DIMENSIONS_BY_SIZE: Record<ToggleSize, ToggleDimensions> = {
small: { width: 24, height: 16, thumbSize: 12 },
medium: { width: 32, height: 20, thumbSize: 16 },
};
const THUMB_INSET_PIXELS = 2;
const getCheckedThumbOffsetPixels = ({
width,
thumbSize,
}: ToggleDimensions) => width - thumbSize - THUMB_INSET_PIXELS;
const StyledToggle = styled.button<{
$toggleSize: ToggleSize;
}>`
align-items: center;
appearance: none;
background-color: ${() => themeCssVariables.background.transparent.medium};
border: none;
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].height}px;
padding: 0;
position: relative;
transition: background-color
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].width}px;
&[data-checked='true'] {
background-color: ${() => themeCssVariables.color.blue};
}
&:disabled {
cursor: default;
opacity: 0.5;
}
`;
const StyledThumb = styled.span<{
$toggleSize: ToggleSize;
}>`
background-color: ${() => themeCssVariables.background.primary};
border-radius: 50%;
corner-shape: round;
display: block;
height: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
left: 0;
position: absolute;
top: 50%;
transform: translate(${THUMB_INSET_PIXELS}px, -50%);
transition: transform
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
&[data-checked='true'] {
transform: translate(
${({ $toggleSize }) =>
getCheckedThumbOffsetPixels(
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize],
)}px,
-50%
);
}
`;
type ToggleProps = {
id?: string;
checked: boolean;
disabled?: boolean;
toggleSize?: ToggleSize;
onChange: (checked: boolean) => void;
};
export const Toggle = ({
id,
checked,
disabled = false,
toggleSize = 'medium',
onChange,
}: ToggleProps) => (
<StyledToggle
type="button"
role="switch"
id={id}
aria-checked={checked ? 'true' : 'false'}
data-checked={checked ? 'true' : 'false'}
disabled={disabled}
$toggleSize={toggleSize}
onClick={() => onChange(!checked)}
>
<StyledThumb
data-checked={checked ? 'true' : 'false'}
$toggleSize={toggleSize}
/>
</StyledToggle>
);

View File

@@ -0,0 +1,120 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-ui/utilities';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { EMPTY_OPTION_LABEL } from 'src/front-components/constants/empty-option-label.constant';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
const StyledListbox = styled.div`
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
`;
const TRANSCRIPT_PROVIDER_LISTBOX_ID = 'call-recorder-transcript-provider';
const getTranscriptProviderOptionId = (index: number) =>
`${TRANSCRIPT_PROVIDER_LISTBOX_ID}-option-${index}`;
type TranscriptProviderControlProps = {
value: string;
options: { label: string; value: string }[];
onChange: (value: string) => void;
};
export const TranscriptProviderControl = ({
value,
options,
onChange,
}: TranscriptProviderControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const selectedOption = options.find((option) => option.value === value);
const selectableOptions = [
{ label: EMPTY_OPTION_LABEL, value: '' },
...options,
];
const selectedOptionIndex = Math.max(
selectableOptions.findIndex(
(selectableOption) => selectableOption.value === value,
),
0,
);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(selectedOptionIndex);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: selectableOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = selectableOptions[activeOptionIndex];
if (isDefined(activeOption)) {
onChange(activeOption.value);
handleMenuClose();
}
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={selectedOption?.label ?? EMPTY_OPTION_LABEL}
ariaLabel="Transcript provider"
listboxId={TRANSCRIPT_PROVIDER_LISTBOX_ID}
activeDescendantId={getTranscriptProviderOptionId(activeOptionIndex)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<StyledListbox
id={TRANSCRIPT_PROVIDER_LISTBOX_ID}
role="listbox"
aria-label="Transcript provider"
>
<DropdownMenuItemsContainer>
{selectableOptions.map((option, index) => (
<DropdownMenuOption
key={option.value}
id={getTranscriptProviderOptionId(index)}
text={option.label}
selected={option.value === value}
isActive={index === activeOptionIndex}
onSelect={() => {
onChange(option.value);
handleMenuClose();
}}
/>
))}
</DropdownMenuItemsContainer>
</StyledListbox>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,133 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS } from 'src/constants/call-recorder-transcript-provider-options';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TranscriptProviderControl } from 'src/front-components/components/TranscriptProviderControl';
import {
CALL_RECORDER_SUMMARY_ENABLED_ROW,
CALL_RECORDER_SUMMARY_PROMPT_FIELD,
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
type TranscriptionSectionProps = {
frontComponentId: string;
};
export const TranscriptionSection = ({
frontComponentId,
}: TranscriptionSectionProps) => {
const inputId = useId();
const [providerValue, setProviderValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
),
);
const [isSummaryEnabled, setIsSummaryEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
) === 'true',
);
const [promptMarkdown, setPromptMarkdown] = useState(
() =>
extractRichTextMarkdown(
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
),
) ?? '',
);
const { saveImmediately: saveProviderImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
});
const { saveImmediately: saveSummaryEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
});
const { saveDebounced: savePromptDebounced } = useAutosaveApplicationVariable(
{
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
},
);
const handleProviderChange = (value: string) => {
setProviderValue(value);
saveProviderImmediately(value);
};
const handleSummaryEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsSummaryEnabled(checked);
saveSummaryEnabledImmediately(value);
};
const handlePromptChange = (value: string) => {
setPromptMarkdown(value);
savePromptDebounced(serializeRichTextMarkdown(value));
};
return (
<Section>
<H2Title
title="Transcription"
description="What happens to a recording once the call ends."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.Icon}
title={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.title}
description={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.description}
divider
>
<TranscriptProviderControl
value={providerValue}
options={CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS}
onChange={handleProviderChange}
/>
</SettingsOptionCardContentSelect>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_SUMMARY_ENABLED_ROW.Icon}
title={CALL_RECORDER_SUMMARY_ENABLED_ROW.title}
description={CALL_RECORDER_SUMMARY_ENABLED_ROW.description}
checked={isSummaryEnabled}
onChange={handleSummaryEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isSummaryEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_SUMMARY_PROMPT_FIELD.label}
inputId={inputId}
hint={CALL_RECORDER_SUMMARY_PROMPT_FIELD.hint}
>
<StyledSettingsTextArea
id={inputId}
placeholder="Value"
disabled={!isSummaryEnabled}
value={promptMarkdown}
onChange={(event) => handlePromptChange(event.target.value)}
onBlur={() => savePromptDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1 @@
export const APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS = 250;

View File

@@ -0,0 +1,139 @@
import {
IconCalendarEvent,
IconClockPlay,
IconColorSwatch,
IconDoorEnter,
IconHourglassHigh,
IconLanguage,
IconMessage,
IconPhoto,
IconPlayerStop,
IconSparkles,
type IconComponent,
} from 'twenty-ui/icon';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-calendar-bot-scheduling-enabled-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-enabled-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH } from 'src/logic-functions/constants/call-recorder-recording-notice-max-length';
import { CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-message-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
export type CallRecorderCounterRow = {
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
};
export const CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW = {
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME,
title: 'Record my calendar meetings',
description: 'Joins every meeting with a supported video link.',
Icon: IconCalendarEvent,
};
export const CALL_RECORDER_RECORDING_NOTICE_ROW = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME,
title: 'Send recording notice',
description:
'Post a notice in the meeting chat when the recorder joins. Zoom, Google Meet and Microsoft Teams only.',
Icon: IconMessage,
};
export const CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME,
label: 'Recording notice message',
hint: `Truncated to ${CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH} characters for Google Meet.`,
};
export const CALL_RECORDER_TIMING_ROWS: CallRecorderCounterRow[] = [
{
variableKey: CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME,
title: 'Join before start',
description:
'Minutes before the scheduled start time. Set 0 to join on time.',
Icon: IconClockPlay,
},
{
variableKey: CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave the lobby after',
description: 'Seconds the recorder waits to be admitted before giving up.',
Icon: IconDoorEnter,
},
{
variableKey: CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave an empty call after',
description: 'Seconds to wait when nobody else ever joins the meeting.',
Icon: IconHourglassHigh,
},
{
variableKey: CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Keep recording after everyone leaves',
description: 'Seconds of grace before the recording is ended.',
Icon: IconPlayerStop,
},
];
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW = {
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME,
title: 'Transcript provider',
description: 'Speech-to-text service used once the call ends.',
Icon: IconLanguage,
};
export const CALL_RECORDER_SUMMARY_ENABLED_ROW = {
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME,
title: 'AI summaries',
description: 'Generate a summary for every recording. Uses AI credits.',
Icon: IconSparkles,
};
export const CALL_RECORDER_USE_WORKSPACE_LOGO_ROW = {
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME,
title: 'Show workspace logo',
description: 'Display your logo on the recorder camera tile.',
Icon: IconPhoto,
};
export const CALL_RECORDER_TILE_BACKGROUND_ROW = {
variableKey: CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME,
title: 'Tile background',
description: 'Colour drawn behind the logo on the camera tile.',
Icon: IconColorSwatch,
};
export const CALL_RECORDER_NAME_FIELD = {
variableKey: CALL_RECORDER_NAME_ENV_VAR_NAME,
label: 'Recorder name',
hint: 'Shown to everyone else in the meeting when the recorder joins.',
};
export const CALL_RECORDER_SUMMARY_PROMPT_FIELD = {
variableKey: CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME,
label: 'Extra summary instructions',
hint: 'Appended to the built-in prompt. Leave empty to use it alone.',
};
// Keep this list aligned with the app config so every application variable is
// intentionally placed in the synchronous settings UI.
export const CALL_RECORDER_MAPPED_VARIABLE_KEYS: string[] = [
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
CALL_RECORDER_NAME_FIELD.variableKey,
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
...CALL_RECORDER_TIMING_ROWS.map((row) => row.variableKey),
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
];

View File

@@ -0,0 +1 @@
export const EMPTY_OPTION_LABEL = 'Select a value';

View File

@@ -0,0 +1,9 @@
export const SETTINGS_SELECT_MENU_GAP_PIXELS = 4;
export const SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS = 320;
export const SETTINGS_SELECT_MENU_WIDTH_PIXELS = 200;
export const SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX = 2000;
export const SETTINGS_SELECT_MENU_Z_INDEX = 2001;

View File

@@ -0,0 +1,33 @@
import { type ThemeColor } from 'twenty-ui/theme';
// twenty-ui's MAIN_COLORS_LIGHT holds display-p3 strings, which the hex
// validator in get-bot-image-background rejects — it would silently fall back
// to the default background. Named colours are stored as the sRGB equivalent
// of the same Radix step instead.
export const THEME_COLOR_HEX: Record<ThemeColor, string> = {
red: '#e5484d',
ruby: '#e54666',
crimson: '#e93d82',
tomato: '#e54d2e',
orange: '#f76b15',
amber: '#ffc53d',
yellow: '#ffe629',
lime: '#bdee63',
grass: '#46a758',
green: '#30a46c',
jade: '#29a383',
mint: '#86ead4',
turquoise: '#12a594',
cyan: '#00a2c7',
sky: '#7ce2fe',
blue: '#3e63dd',
iris: '#5b5bd6',
violet: '#6e56cf',
purple: '#8e4ec6',
plum: '#ab4aba',
pink: '#d6409f',
bronze: '#a18072',
gold: '#978365',
brown: '#ad7f58',
gray: '#999999',
};

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const GenerateCallRecordingSummary = () => {
const calendarEventIds = useSelectedRecordIds();
return (
<Command
execute={() =>
requestCallRecordingSummaryGeneration({ calendarEventIds })
}
/>
);
};
export default defineFrontComponent({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-call-recording-summary-effect',
description:
'Requests AI summary generation for the call recordings of the selected calendar events.',
component: GenerateCallRecordingSummary,
isHeadless: true,
});

View File

@@ -0,0 +1,75 @@
import { isUndefined } from '@sniptt/guards';
import { useCallback, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS } from 'src/front-components/constants/application-variable-save-debounce.constant';
import { useSaveApplicationVariable } from 'src/front-components/hooks/use-save-application-variable';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
type UseAutosaveApplicationVariableParams = {
frontComponentId: string;
variableKey: string;
onSaveSuccess?: (value: string) => void | Promise<void>;
onSaveError?: (value: string) => void;
};
export const useAutosaveApplicationVariable = ({
frontComponentId,
variableKey,
onSaveSuccess,
onSaveError,
}: UseAutosaveApplicationVariableParams) => {
const { saveApplicationVariable } =
useSaveApplicationVariable(frontComponentId);
const saveApplicationVariableRef = useRef(saveApplicationVariable);
const onSaveSuccessRef = useRef(onSaveSuccess);
const onSaveErrorRef = useRef(onSaveError);
const variableKeyRef = useRef(variableKey);
saveApplicationVariableRef.current = saveApplicationVariable;
onSaveSuccessRef.current = onSaveSuccess;
onSaveErrorRef.current = onSaveError;
variableKeyRef.current = variableKey;
const saveQueueRef = useRef<
ReturnType<typeof createApplicationVariableSaveQueue> | undefined
>(undefined);
if (isUndefined(saveQueueRef.current)) {
saveQueueRef.current = createApplicationVariableSaveQueue({
saveValue: async (value, isSupersededValue) => {
const isSaved = await saveApplicationVariableRef.current({
variableKey: variableKeyRef.current,
value,
});
if (isSaved) {
await onSaveSuccessRef.current?.(value);
return;
}
if (isSupersededValue()) {
return;
}
onSaveErrorRef.current?.(value);
},
});
}
const saveDebounced = useDebouncedCallback(
saveQueueRef.current.enqueueSave,
APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS,
);
const saveImmediately = useCallback(
(value: string) => {
saveDebounced.cancel();
saveQueueRef.current?.enqueueSave(value);
},
[saveDebounced],
);
return { saveDebounced, saveImmediately };
};

View File

@@ -0,0 +1,31 @@
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { useUpdateApplicationVariable } from 'src/front-components/hooks/use-update-application-variable';
type SaveApplicationVariableParams = {
variableKey: string;
value: string;
};
export const useSaveApplicationVariable = (frontComponentId: string) => {
const { updateApplicationVariable } =
useUpdateApplicationVariable(frontComponentId);
const saveApplicationVariable = async ({
variableKey,
value,
}: SaveApplicationVariableParams) => {
const isUpdated = await updateApplicationVariable({ variableKey, value });
if (!isUpdated) {
enqueueSnackbar({
message: `Could not save ${variableKey}.`,
variant: 'error',
});
}
return isUpdated;
};
return { saveApplicationVariable };
};

View File

@@ -0,0 +1,41 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
type UpdateApplicationVariableParams = {
variableKey: string;
value: string;
};
type UpdateApplicationVariableState = {
updateApplicationVariable: (
params: UpdateApplicationVariableParams,
) => Promise<boolean>;
};
export const useUpdateApplicationVariable = (
frontComponentId: string,
): UpdateApplicationVariableState => {
const updateApplicationVariable = async ({
variableKey,
value,
}: UpdateApplicationVariableParams): Promise<boolean> => {
try {
const applicationId =
await resolveCallRecorderApplicationId(frontComponentId);
const client = new MetadataApiClient();
await client.mutation({
updateOneApplicationVariable: {
__args: { key: variableKey, value, applicationId },
},
});
return true;
} catch {
return false;
}
};
return { updateApplicationVariable };
};

View File

@@ -0,0 +1,12 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-settings-front-component-universal-identifier';
import { CallRecorderSettings } from 'src/front-components/components/CallRecorderSettings';
export default defineSettingsFrontComponent({
universalIdentifier:
CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'call-recorder-settings',
description: 'Settings panel for the call recorder application variables.',
component: CallRecorderSettings,
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
const createDeferredPromise = () => {
let resolvePromise: () => void = () => {};
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolvePromise };
};
describe('createApplicationVariableSaveQueue', () => {
it('persists one value at a time and coalesces queued values to the latest', async () => {
const firstSave = createDeferredPromise();
const latestSave = createDeferredPromise();
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockReturnValueOnce(firstSave.promise)
.mockReturnValueOnce(latestSave.promise);
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('second');
enqueueSave('latest');
expect(saveValue).toHaveBeenCalledTimes(1);
expect(saveValue).toHaveBeenNthCalledWith(1, 'first', expect.any(Function));
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(2);
expect(saveValue).toHaveBeenNthCalledWith(
2,
'latest',
expect.any(Function),
);
latestSave.resolvePromise();
await latestSave.promise;
});
it('reports a value as superseded only while a newer one is queued', async () => {
const firstSave = createDeferredPromise();
const supersededByValue: Record<string, boolean> = {};
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockImplementation(async (value, isSupersededValue) => {
if (value === 'first') {
await firstSave.promise;
}
supersededByValue[value] = isSupersededValue();
});
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('latest');
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
await Promise.resolve();
expect(supersededByValue).toEqual({ first: true, latest: false });
});
});

View File

@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
describe('getApplicationVariableValue', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('reads the value injected into the front component synchronously', () => {
vi.stubEnv(
'applicationVariables',
JSON.stringify({ RECORDER_NAME: 'Meeting assistant' }),
);
expect(getApplicationVariableValue('RECORDER_NAME')).toBe(
'Meeting assistant',
);
});
it('returns an empty value when the variable is not configured', () => {
vi.stubEnv('applicationVariables', '{}');
expect(getApplicationVariableValue('RECORDER_NAME')).toBe('');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getNextActiveOptionIndex } from 'src/front-components/utils/get-next-active-option-index.util';
describe('getNextActiveOptionIndex', () => {
it.each([
{ key: 'ArrowDown' as const, currentIndex: 1, expectedIndex: 2 },
{ key: 'ArrowDown' as const, currentIndex: 3, expectedIndex: 0 },
{ key: 'ArrowUp' as const, currentIndex: 2, expectedIndex: 1 },
{ key: 'ArrowUp' as const, currentIndex: 0, expectedIndex: 3 },
{ key: 'Home' as const, currentIndex: 2, expectedIndex: 0 },
{ key: 'End' as const, currentIndex: 1, expectedIndex: 3 },
])(
'returns $expectedIndex for $key from $currentIndex',
({ key, currentIndex, expectedIndex }) => {
expect(
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: 4,
}),
).toBe(expectedIndex);
},
);
it('returns zero when there are no options', () => {
expect(
getNextActiveOptionIndex({
key: 'ArrowDown',
currentIndex: 0,
optionCount: 0,
}),
).toBe(0);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
describe('getNormalizedHexValue', () => {
it('normalizes valid hex values', () => {
expect(getNormalizedHexValue('#1D1D1D')).toBe('#1d1d1d');
expect(getNormalizedHexValue(' #abc ')).toBe('#aabbcc');
});
it('keeps an empty value so the variable can be cleared', () => {
expect(getNormalizedHexValue('')).toBe('');
expect(getNormalizedHexValue(' ')).toBe('');
});
it('returns undefined for values that are not hex colours', () => {
expect(getNormalizedHexValue('#12')).toBeUndefined();
expect(getNormalizedHexValue('1d1d1d')).toBeUndefined();
expect(getNormalizedHexValue('red')).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
describe('getNormalizedNumberValue', () => {
it('normalizes numeric values', () => {
expect(getNormalizedNumberValue('0')).toBe('0');
expect(getNormalizedNumberValue(' 7 ')).toBe('7');
expect(getNormalizedNumberValue('-1.50')).toBe('-1.5');
});
it('keeps an empty value so a variable can be cleared', () => {
expect(getNormalizedNumberValue('')).toBe('');
expect(getNormalizedNumberValue(' ')).toBe('');
});
it('returns undefined for values that are not numbers', () => {
expect(getNormalizedNumberValue('abc')).toBeUndefined();
expect(getNormalizedNumberValue('12abc')).toBeUndefined();
expect(getNormalizedNumberValue('Infinity')).toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
describe('getThemeColorFromHex', () => {
it('maps a stored hex back to its theme colour name', () => {
expect(getThemeColorFromHex('#3E63DD')).toBe('blue');
expect(getThemeColorFromHex('#e5484d')).toBe('red');
});
it('returns undefined for a colour outside the palette', () => {
expect(getThemeColorFromHex('#1d1d1d')).toBeUndefined();
});
it('stores every palette colour as a hex the bot image validator accepts', () => {
const hexPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
for (const hex of Object.values(THEME_COLOR_HEX)) {
expect(hex).toMatch(hexPattern);
}
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
describe('normalizeHexColor', () => {
it('expands short hex values', () => {
expect(normalizeHexColor('#ABC')).toBe('#aabbcc');
});
it('lowercases long hex values', () => {
expect(normalizeHexColor(' #1D1D1D ')).toBe('#1d1d1d');
});
it('rejects anything that is not a hex colour', () => {
expect(
normalizeHexColor('color(display-p3 0.83 0.329 0.324)'),
).toBeUndefined();
expect(normalizeHexColor('red')).toBeUndefined();
expect(normalizeHexColor('')).toBeUndefined();
});
});

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const enqueueSnackbarMock = vi.hoisted(() => vi.fn());
const postMock = vi.hoisted(() => vi.fn());
const restApiClientMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/front-component', () => ({
enqueueSnackbar: enqueueSnackbarMock,
}));
vi.mock('twenty-client-sdk/rest', () => ({
RestApiClient: restApiClientMock,
}));
describe('requestCallRecordingSummaryGeneration', () => {
beforeEach(() => {
vi.clearAllMocks();
restApiClientMock.mockImplementation(function RestApiClient() {
return { post: postMock };
});
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
});
});
it('posts the /s-prefixed route path and lets the client resolve the url', async () => {
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(restApiClientMock).toHaveBeenCalledWith();
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1'] },
);
});
it('does nothing when no calendar events are selected', async () => {
await requestCallRecordingSummaryGeneration({ calendarEventIds: [] });
expect(postMock).not.toHaveBeenCalled();
expect(enqueueSnackbarMock).not.toHaveBeenCalled();
});
it('reports mixed generation results', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: ['call-recording-2'],
erroredCallRecordingIds: [],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
);
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Some summaries generated, some failed.',
variant: 'error',
});
});
it('reports generation errors as failed summaries', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary generation failed.',
variant: 'error',
});
});
it('reports when the call recording has no usable transcript', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'No usable call recording transcript found for this event.',
variant: 'error',
});
});
it('reports when the transcript has nothing substantive to summarize', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
unavailableCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary unavailable for this event.',
variant: 'error',
});
});
});

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { metadataQuery } = vi.hoisted(() => ({
metadataQuery: vi.fn(),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: class {
query = metadataQuery;
},
}));
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
describe('resolveCallRecorderApplicationId', () => {
beforeEach(() => {
metadataQuery.mockReset();
});
it('shares one application lookup across concurrent saves', async () => {
metadataQuery.mockResolvedValue({
frontComponent: { applicationId: 'application-id' },
});
const firstResolution =
resolveCallRecorderApplicationId('front-component-id');
const secondResolution =
resolveCallRecorderApplicationId('front-component-id');
await expect(firstResolution).resolves.toBe('application-id');
await expect(secondResolution).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledOnce();
});
it('retries the lookup after a failed resolution', async () => {
metadataQuery
.mockResolvedValueOnce({ frontComponent: null })
.mockResolvedValueOnce({
frontComponent: { applicationId: 'application-id' },
});
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).rejects.toThrow('Could not resolve the call recorder application.');
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
describe('serializeRichTextMarkdown', () => {
it('wraps markdown into the stored rich text shape', () => {
expect(serializeRichTextMarkdown('Write terse notes.')).toBe(
'{"blocknote":null,"markdown":"Write terse notes."}',
);
});
it('serializes an empty prompt using the stored rich text shape', () => {
expect(serializeRichTextMarkdown('')).toBe(
'{"blocknote":null,"markdown":null}',
);
});
it('round-trips through the reader used by the summary prompt', () => {
expect(
extractRichTextMarkdown(serializeRichTextMarkdown('Focus on decisions.')),
).toBe('Focus on decisions.');
});
});

View File

@@ -0,0 +1,47 @@
import { isUndefined } from '@sniptt/guards';
type CreateApplicationVariableSaveQueueParams = {
saveValue: (
value: string,
isSupersededValue: () => boolean,
) => Promise<void>;
};
export const createApplicationVariableSaveQueue = ({
saveValue,
}: CreateApplicationVariableSaveQueueParams) => {
let queuedValue: string | undefined;
let isSaving = false;
const persistQueuedValues = async () => {
if (isSaving) {
return;
}
isSaving = true;
try {
while (!isUndefined(queuedValue)) {
const valueToSave = queuedValue;
queuedValue = undefined;
await saveValue(valueToSave, () => !isUndefined(queuedValue));
}
} finally {
isSaving = false;
if (!isUndefined(queuedValue)) {
void persistQueuedValues();
}
}
};
const enqueueSave = (value: string) => {
queuedValue = value;
void persistQueuedValues();
};
return { enqueueSave };
};

View File

@@ -0,0 +1,4 @@
import { getApplicationVariable } from 'twenty-sdk/front-component';
export const getApplicationVariableValue = (variableKey: string): string =>
getApplicationVariable(variableKey) ?? '';

View File

@@ -0,0 +1,33 @@
export type SettingsSelectNavigationKey =
| 'ArrowDown'
| 'ArrowUp'
| 'Home'
| 'End';
export const getNextActiveOptionIndex = ({
key,
currentIndex,
optionCount,
}: {
key: SettingsSelectNavigationKey;
currentIndex: number;
optionCount: number;
}): number => {
if (optionCount <= 0) {
return 0;
}
if (key === 'ArrowDown') {
return (currentIndex + 1) % optionCount;
}
if (key === 'ArrowUp') {
return (currentIndex - 1 + optionCount) % optionCount;
}
if (key === 'Home') {
return 0;
}
return optionCount - 1;
};

View File

@@ -0,0 +1,9 @@
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getNormalizedHexValue = (value: string): string | undefined => {
if (value.trim() === '') {
return '';
}
return normalizeHexColor(value);
};

View File

@@ -0,0 +1,15 @@
// Returns undefined when the draft cannot be stored, so a half-typed number is
// kept on screen instead of being persisted as a broken value.
export const getNormalizedNumberValue = (
value: string,
): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue === '') {
return '';
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? String(parsedValue) : undefined;
};

View File

@@ -0,0 +1,37 @@
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors the settings inputs twenty-front renders for application variables,
// which cannot be imported here: twenty-ui ships no text, select or textarea
// input, and front components cannot reach into twenty-front internals.
export const getSettingsControlStyles = () => `
background-color: ${themeCssVariables.background.transparent.light};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
outline: none;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.tertiary};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.secondary};
}
&[readonly] {
pointer-events: none;
}
&:focus {
border-color: ${themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1,17 @@
import { isUndefined } from '@sniptt/guards';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getThemeColorFromHex = (value: string): ThemeColor | undefined => {
const normalizedValue = normalizeHexColor(value);
if (isUndefined(normalizedValue)) {
return undefined;
}
return MAIN_COLOR_NAMES.find(
(colorName) => THEME_COLOR_HEX[colorName] === normalizedValue,
);
};

View File

@@ -0,0 +1,16 @@
const SHORT_HEX_PATTERN = /^#[0-9a-f]{3}$/;
const LONG_HEX_PATTERN = /^#[0-9a-f]{6}$/;
export const normalizeHexColor = (value: string): string | undefined => {
const normalizedValue = value.trim().toLowerCase();
if (SHORT_HEX_PATTERN.test(normalizedValue)) {
return `#${normalizedValue
.slice(1)
.split('')
.map((character) => `${character}${character}`)
.join('')}`;
}
return LONG_HEX_PATTERN.test(normalizedValue) ? normalizedValue : undefined;
};

Some files were not shown because too many files have changed in this diff Show More