Files
twenty/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts
T
Charles Bochet 563acc3f57 Allow copy to clipboard and pointer/mousemove events in front components (#20858)
Follow-up to #20525, picks up the clipboard + mouse/pointer events asks
from the "Allow to copy to clipboard in front-component" Slack thread.
`navigator.geolocation` and `getBoundingClientRect` are intentionally
out of scope until we have a permission model.

### `copyToClipboard` host API

New SDK function `copyToClipboard` (in `twenty-sdk/front-component`)
that goes through the host bridge to `useCopyToClipboard` in
`twenty-front`:

```ts
import { copyToClipboard } from 'twenty-sdk/front-component';

await copyToClipboard('hello');
```

Host-side hardening (front-component code is untrusted):
- Drops anything that isn't a non-empty string
- Caps payload at 64KB
- Throttles to 1 call/sec per front-component instance
- Snackbar shows a truncated preview so the user can spot a mismatch
between the affordance they clicked and what actually got copied

### `mousemove` and pointer events

Added to `COMMON_HTML_EVENTS` (and the React mapping) so they fire on
every HTML tag the renderer ships: `mousemove`, `pointerdown/up/move`,
`pointerover/out/enter/leave/cancel`. Generator rerun for
`remote-elements.ts` and `remote-components.ts`.

`SerializedEventData` now also forwards pointer geometry: `pointerId`,
`pointerType`, `pressure`, `tangentialPressure`, `tiltX/Y`, `twist`,
`width/height`, `isPrimary`. Existing positional fields are unchanged.

### Coverage

- New Storybook stories: `HostApi/CopyToClipboard` and
`HtmlTag/Grouping/Div/Events::PointerMove`
- `useFrontComponentExecutionContext` unit tests cover the API call,
preview truncation, type guard, length cap, and rate limit
- Renderer Storybook suite 227 → 229, prebuild bundle count 219 → 221
2026-05-23 08:28:11 +00:00

215 lines
7.0 KiB
TypeScript

import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isNonEmptyString } from '@sniptt/guards';
import { useLingui } from '@lingui/react/macro';
import { useRef } from 'react';
import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
} from 'twenty-front-component-renderer';
import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
import { useUnmountCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH = 64 * 1024;
const FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS = 1000;
const FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH = 30;
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
selectedRecordIds,
}: {
frontComponentId: string;
commandMenuItemId?: string;
selectedRecordIds?: string[];
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
} => {
const currentUser = useAtomStateValue(currentUserState);
const navigateApp = useNavigateApp();
const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({
frontComponentId,
});
const { openConfirmationModal } = useCommandMenuConfirmationModal();
const { navigateSidePanel } = useNavigateSidePanel();
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
const { getIcon } = useIcons();
const unmountEngineCommand = useUnmountCommand();
const {
enqueueSuccessSnackBar,
enqueueErrorSnackBar,
enqueueInfoSnackBar,
enqueueWarningSnackBar,
} = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const { copyToClipboard: copyToClipboardWithSnackbar } = useCopyToClipboard();
const { t } = useLingui();
// oxlint-disable-next-line twenty/no-state-useref
const lastCopyToClipboardCallAtRef = useRef<number>(Number.NEGATIVE_INFINITY);
const setCommandMenuItemProgress = useSetAtomFamilyState(
commandMenuItemProgressFamilyState,
commandMenuItemId ?? '',
);
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
to,
params,
queryParams,
options,
) => {
navigateApp(
to as AppPath,
params as Parameters<typeof navigateApp>[1],
queryParams,
options,
);
};
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
async ({ page, pageTitle, pageIcon, shouldResetSearchState }) => {
navigateSidePanel({
page,
pageTitle,
pageIcon: getIcon(pageIcon),
});
if (shouldResetSearchState === true) {
setSidePanelSearch('');
}
};
const openCommandConfirmationModal: FrontComponentHostCommunicationApi['openCommandConfirmationModal'] =
async ({ title, subtitle, confirmButtonText, confirmButtonAccent }) => {
openConfirmationModal({
caller: { type: 'frontComponent', frontComponentId },
title,
subtitle,
confirmButtonText,
confirmButtonAccent,
});
};
const enqueueSnackbar: FrontComponentHostCommunicationApi['enqueueSnackbar'] =
async ({
message,
variant,
duration,
detailedMessage,
dedupeKey,
}: EnqueueSnackbarParams) => {
const snackBarOptions = {
duration,
detailedMessage,
dedupeKey,
};
switch (variant) {
case 'error':
enqueueErrorSnackBar({ message, options: snackBarOptions });
break;
case 'info':
enqueueInfoSnackBar({ message, options: snackBarOptions });
break;
case 'warning':
enqueueWarningSnackBar({ message, options: snackBarOptions });
break;
case 'success':
enqueueSuccessSnackBar({ message, options: snackBarOptions });
break;
default:
assertUnreachable(variant);
}
};
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null,
selectedRecordIds: selectedRecordIds ?? [],
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
async () => {
if (isDefined(commandMenuItemId)) {
unmountEngineCommand(commandMenuItemId);
}
};
const closeSidePanel: FrontComponentHostCommunicationApi['closeSidePanel'] =
async () => {
closeSidePanelMenu();
};
const updateProgress: FrontComponentHostCommunicationApi['updateProgress'] =
async (progress) => {
if (!isDefined(commandMenuItemId)) {
return;
}
setCommandMenuItemProgress(Math.max(0, Math.min(100, progress)));
};
const copyToClipboard: FrontComponentHostCommunicationApi['copyToClipboard'] =
async (text) => {
if (!isNonEmptyString(text)) {
return;
}
if (text.length > FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH) {
return;
}
const now = Date.now();
if (
now - lastCopyToClipboardCallAtRef.current <
FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS
) {
return;
}
lastCopyToClipboardCallAtRef.current = now;
const preview =
text.length > FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH
? `${text.slice(0, FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH)}`
: text;
await copyToClipboardWithSnackbar(
text,
t`Application copied "${preview}" to your clipboard`,
);
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
requestAccessTokenRefresh,
openSidePanelPage,
openCommandConfirmationModal,
enqueueSnackbar,
unmountFrontComponent,
closeSidePanel,
updateProgress,
copyToClipboard,
};
return {
executionContext,
frontComponentHostCommunicationApi,
};
};