diff --git a/.changeset/sixty-numbers-tan.md b/.changeset/sixty-numbers-tan.md new file mode 100644 index 0000000000000..d0c6d9367cf85 --- /dev/null +++ b/.changeset/sixty-numbers-tan.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/i18n": minor +"@rocket.chat/ui-voip": minor +--- + +Introduces native desktop notifications for voice calls when using the desktop app. diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 640ef7f49df6f..0984f1b3c9bd2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2560,6 +2560,7 @@ "Incoming_Livechats": "Queued chats", "Incoming_WebHook": "Incoming WebHook", "Incoming_call": "Incoming call", + "Incoming_call_ellipsis": "Incoming call...", "Incoming_call_from": "Incoming call from", "Incoming_call_from__roomName__": "Incoming call from {{roomName}}", "Incoming_call_transfer": "Incoming call transfer", diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index df20769403453..7fdb5ec514721 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" }, "dependencies": { + "@rocket.chat/desktop-api": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/media-signaling": "workspace:~", "@tanstack/react-query": "~5.65.1", diff --git a/packages/ui-voip/src/global.d.ts b/packages/ui-voip/src/global.d.ts new file mode 100644 index 0000000000000..51c30e263ccad --- /dev/null +++ b/packages/ui-voip/src/global.d.ts @@ -0,0 +1,7 @@ +import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; + +declare global { + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/packages/ui-voip/src/v2/MediaCallProvider.tsx b/packages/ui-voip/src/v2/MediaCallProvider.tsx index 069c13b307d02..e1a80109e2daa 100644 --- a/packages/ui-voip/src/v2/MediaCallProvider.tsx +++ b/packages/ui-voip/src/v2/MediaCallProvider.tsx @@ -11,7 +11,7 @@ import { useToastMessageDispatch, useSetting, } from '@rocket.chat/ui-contexts'; -import { useCallback, useEffect } from 'react'; +import { ReactNode, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -19,13 +19,18 @@ import MediaCallContext, { PeerInfo } from './MediaCallContext'; import MediaCallWidget from './MediaCallWidget'; import TransferModal from './TransferModal'; import { useCallSounds } from './useCallSounds'; +import { useDesktopNotifications } from './useDesktopNotifications'; import { getExtensionFromPeerInfo, useMediaSession } from './useMediaSession'; import { useMediaSessionInstance } from './useMediaSessionInstance'; import useMediaStream from './useMediaStream'; import { isValidTone, useTonePlayer } from './useTonePlayer'; import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt'; -const MediaCallProvider = ({ children }: { children: React.ReactNode }) => { +type MediaCallProviderProps = { + children: ReactNode; +}; + +const MediaCallProvider = ({ children }: MediaCallProviderProps) => { const user = useUser(); const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -37,6 +42,8 @@ const MediaCallProvider = ({ children }: { children: React.ReactNode }) => { const instance = useMediaSessionInstance(userId ?? undefined); const session = useMediaSession(instance); + useDesktopNotifications(session); + const [remoteStreamRefCallback, audioElement] = useMediaStream(instance); const setOutputMediaDevice = useSetOutputMediaDevice(); diff --git a/packages/ui-voip/src/v2/MediaCallWidget.stories.tsx b/packages/ui-voip/src/v2/MediaCallWidget.stories.tsx index 84c6d34bc46d1..4fe83d06c602e 100644 --- a/packages/ui-voip/src/v2/MediaCallWidget.stories.tsx +++ b/packages/ui-voip/src/v2/MediaCallWidget.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useMediaCallContext } from './MediaCallContext'; import MediaCallWidget from './MediaCallWidget'; -import MediaCallProviderMock from './MockedMediaCallProvider'; +import MockedMediaCallProvider from './MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -26,9 +26,9 @@ const meta = { decorators: [ mockedContexts, (Story, options) => ( - + - + ), ], } satisfies Meta; diff --git a/packages/ui-voip/src/v2/MockedMediaCallProvider.tsx b/packages/ui-voip/src/v2/MockedMediaCallProvider.tsx index 1da93f4a3206e..b49dc37bf74d1 100644 --- a/packages/ui-voip/src/v2/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/v2/MockedMediaCallProvider.tsx @@ -1,5 +1,5 @@ import { UserStatus } from '@rocket.chat/core-typings'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import MediaCallContext from './MediaCallContext'; import type { State, PeerInfo } from './MediaCallContext'; @@ -7,7 +7,17 @@ import type { State, PeerInfo } from './MediaCallContext'; const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`; const myData: any[] = Array.from({ length: 100 }, (_, i) => ({ value: `user-${i}`, label: `User ${i}`, identifier: `000${i}`, avatarUrl })); -const MediaCallProviderMock = ({ +type MockedMediaCallProviderProps = { + children: ReactNode; + state?: State; + transferredBy?: string; + remoteMuted?: boolean; + remoteHeld?: boolean; + muted?: boolean; + held?: boolean; +}; + +const MockedMediaCallProvider = ({ children, state = 'closed', transferredBy = undefined, @@ -15,15 +25,7 @@ const MediaCallProviderMock = ({ remoteHeld = false, muted = false, held = false, -}: { - children: React.ReactNode; - state?: State; - transferredBy?: string; - remoteMuted?: boolean; - remoteHeld?: boolean; - muted?: boolean; - held?: boolean; -}) => { +}: MockedMediaCallProviderProps) => { const [peerInfo, setPeerInfo] = useState({ displayName: 'John Doe', userId: '1234567890', @@ -147,4 +149,4 @@ const MediaCallProviderMock = ({ return {children}; }; -export default MediaCallProviderMock; +export default MockedMediaCallProvider; diff --git a/packages/ui-voip/src/v2/components/Widget/Widget.tsx b/packages/ui-voip/src/v2/components/Widget/Widget.tsx index 3b87d06a04026..5ba332a4be626 100644 --- a/packages/ui-voip/src/v2/components/Widget/Widget.tsx +++ b/packages/ui-voip/src/v2/components/Widget/Widget.tsx @@ -1,6 +1,6 @@ import { Palette } from '@rocket.chat/fuselage'; import styled from '@rocket.chat/styled'; -import { ComponentProps, useLayoutEffect } from 'react'; +import { ComponentProps, ReactNode, useLayoutEffect } from 'react'; import { FocusScope } from 'react-aria'; import { DragContext } from './WidgetDraggableContext'; @@ -28,7 +28,7 @@ const WidgetBase = styled('article')` `; type WidgetProps = { - children: React.ReactNode; + children: ReactNode; } & ComponentProps; const Widget = ({ children, ...props }: WidgetProps) => { diff --git a/packages/ui-voip/src/v2/useDesktopNotifications.ts b/packages/ui-voip/src/v2/useDesktopNotifications.ts new file mode 100644 index 0000000000000..2038c54f5d715 --- /dev/null +++ b/packages/ui-voip/src/v2/useDesktopNotifications.ts @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PeerInfo } from './MediaCallContext'; +import { SessionInfo } from './useMediaSessionInstance'; +import { convertAvatarUrlToPng } from './utils/convertAvatarUrlToPng'; + +const getDisplayInfo = (peerInfo?: PeerInfo) => { + if (!peerInfo) { + return undefined; + } + + if ('number' in peerInfo) { + return { title: peerInfo.number }; + } + if ('displayName' in peerInfo) { + return { title: peerInfo.displayName, avatar: peerInfo.avatarUrl }; + } + return undefined; +}; + +export const useDesktopNotifications = (sessionInfo: SessionInfo) => { + const previousCallId = useRef(undefined); + const { t } = useTranslation(); + + const displayInfo = getDisplayInfo(sessionInfo.peerInfo); + useEffect(() => { + if ( + typeof window.RocketChatDesktop?.dispatchCustomNotification !== 'function' || + typeof window.RocketChatDesktop?.closeCustomNotification !== 'function' + ) { + return; + } + + let isMounted = true; + + if (sessionInfo.state !== 'ringing') { + if (previousCallId.current) { + window.RocketChatDesktop.closeCustomNotification(previousCallId.current); + previousCallId.current = undefined; + } + return; + } + + if (!displayInfo?.title) { + return; + } + + const notifyDesktop = async () => { + const avatarAsPng = await convertAvatarUrlToPng(displayInfo.avatar); + + if (!isMounted) { + return; + } + + window.RocketChatDesktop?.dispatchCustomNotification({ + type: 'voice', + id: sessionInfo.callId, + payload: { + title: displayInfo.title, + body: t('Incoming_call_ellipsis'), + avatar: avatarAsPng || undefined, + requireInteraction: true, + }, + }); + }; + + notifyDesktop(); + previousCallId.current = sessionInfo.callId; + + return () => { + isMounted = false; + }; + }, [displayInfo?.avatar, displayInfo?.title, sessionInfo.callId, sessionInfo.state, t]); +}; diff --git a/packages/ui-voip/src/v2/useKeypad.tsx b/packages/ui-voip/src/v2/useKeypad.tsx index 159cd6726496a..230776edacaaf 100644 --- a/packages/ui-voip/src/v2/useKeypad.tsx +++ b/packages/ui-voip/src/v2/useKeypad.tsx @@ -1,11 +1,11 @@ import { Divider, Box, TextInput, Field, FieldRow } from '@rocket.chat/fuselage'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Keypad } from './components'; type UseKeypad = { - element: React.ReactNode; + element: ReactNode; buttonProps: { title: string; onClick: () => void; diff --git a/packages/ui-voip/src/v2/useMediaSession.ts b/packages/ui-voip/src/v2/useMediaSession.ts index 062f30e07fec0..ab2bfe206b7ce 100644 --- a/packages/ui-voip/src/v2/useMediaSession.ts +++ b/packages/ui-voip/src/v2/useMediaSession.ts @@ -8,6 +8,7 @@ import type { SessionInfo } from './useMediaSessionInstance'; const defaultSessionInfo: SessionInfo = { state: 'closed' as const, + callId: undefined, connectionState: 'CONNECTING' as const, peerInfo: undefined, transferredBy: undefined, @@ -48,7 +49,10 @@ export const getExtensionFromPeerInfo = (peerInfo: PeerInfo): string | undefined return undefined; }; -const deriveWidgetStateFromCallState = (callState: CallState, callRole: CallRole): State | undefined => { +const deriveWidgetStateFromCallState = ( + callState: CallState, + callRole: CallRole, +): Extract | undefined => { switch (callState) { case 'active': case 'accepted': @@ -143,7 +147,18 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession return; } - const { contact, transferredBy: callTransferredBy, state: callState, role, muted, held, hidden, remoteHeld, remoteMute } = mainCall; + const { + contact, + transferredBy: callTransferredBy, + state: callState, + role, + muted, + held, + hidden, + remoteHeld, + remoteMute, + callId, + } = mainCall; const state = deriveWidgetStateFromCallState(callState, role); const connectionState = deriveConnectionStateFromCallState(callState); @@ -162,6 +177,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession hidden, remoteHeld, remoteMuted: remoteMute, + callId, }, }); return; @@ -189,7 +205,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession dispatch({ type: 'instance_updated', - payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute }, + payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute, callId }, }); }; diff --git a/packages/ui-voip/src/v2/useMediaSessionInstance.ts b/packages/ui-voip/src/v2/useMediaSessionInstance.ts index d726191fa7cc4..c9c0da0db687f 100644 --- a/packages/ui-voip/src/v2/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/v2/useMediaSessionInstance.ts @@ -23,10 +23,12 @@ interface BaseSession { interface EmptySession extends BaseSession { state: Extract; + callId: undefined; } interface CallSession extends BaseSession { state: Extract; + callId: string; peerInfo: PeerInfo; } diff --git a/packages/ui-voip/src/v2/utils/convertAvatarUrlToPng.ts b/packages/ui-voip/src/v2/utils/convertAvatarUrlToPng.ts new file mode 100644 index 0000000000000..45da47cdcd55a --- /dev/null +++ b/packages/ui-voip/src/v2/utils/convertAvatarUrlToPng.ts @@ -0,0 +1,36 @@ +export const convertAvatarUrlToPng = (avatarUrl: string | undefined): Promise => { + return new Promise((resolve) => { + if (!avatarUrl) { + resolve(''); + return; + } + + const image = new Image(); + + const onLoad = (): void => { + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + + if (!context) { + resolve(''); + return; + } + + context.drawImage(image, 0, 0); + try { + resolve(canvas.toDataURL('image/png')); + } catch (e) { + resolve(''); + } + }; + + const onError = (): void => resolve(''); + + image.onload = onLoad; + image.onerror = onError; + image.crossOrigin = 'anonymous'; + image.src = avatarUrl; + }); +}; diff --git a/packages/ui-voip/src/v2/views/IncomingCall.stories.tsx b/packages/ui-voip/src/v2/views/IncomingCall.stories.tsx index 698e5936334dc..ffc34e625c3c6 100644 --- a/packages/ui-voip/src/v2/views/IncomingCall.stories.tsx +++ b/packages/ui-voip/src/v2/views/IncomingCall.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import IncomingCall from './IncomingCall'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -18,9 +18,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; diff --git a/packages/ui-voip/src/v2/views/IncomingCallTransfer.stories.tsx b/packages/ui-voip/src/v2/views/IncomingCallTransfer.stories.tsx index b639b1e708b40..a0a48f3de288c 100644 --- a/packages/ui-voip/src/v2/views/IncomingCallTransfer.stories.tsx +++ b/packages/ui-voip/src/v2/views/IncomingCallTransfer.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import IncomingCallTransfer from './IncomingCallTransfer'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -19,9 +19,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; diff --git a/packages/ui-voip/src/v2/views/NewCall.stories.tsx b/packages/ui-voip/src/v2/views/NewCall.stories.tsx index 736e729780a6f..953485f7c4921 100644 --- a/packages/ui-voip/src/v2/views/NewCall.stories.tsx +++ b/packages/ui-voip/src/v2/views/NewCall.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import NewCall from './NewCall'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -18,9 +18,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; diff --git a/packages/ui-voip/src/v2/views/OngoingCall.stories.tsx b/packages/ui-voip/src/v2/views/OngoingCall.stories.tsx index 68944f2128420..a8158cfb388a4 100644 --- a/packages/ui-voip/src/v2/views/OngoingCall.stories.tsx +++ b/packages/ui-voip/src/v2/views/OngoingCall.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import OngoingCall from './OngoingCall'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot().buildStoryDecorator(); @@ -12,9 +12,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; @@ -25,40 +25,40 @@ export const OngoingCallStory: StoryFn = () => { export const OngoingCallWithSlots: StoryFn = () => { return ( - + - + ); }; export const OngoingCallWithRemoteStatus: StoryFn = () => { return ( - + - + ); }; export const OngoingCallWithRemoteStatusMuted: StoryFn = () => { return ( - + - + ); }; export const OngoingCallWithRemoteStatusHeld: StoryFn = () => { return ( - + - + ); }; export const OngoingCallWithSlotsAndRemoteStatus: StoryFn = () => { return ( - + - + ); }; diff --git a/packages/ui-voip/src/v2/views/OutgoingCall.stories.tsx b/packages/ui-voip/src/v2/views/OutgoingCall.stories.tsx index 74e7f0ecfaf51..fb02940f146c0 100644 --- a/packages/ui-voip/src/v2/views/OutgoingCall.stories.tsx +++ b/packages/ui-voip/src/v2/views/OutgoingCall.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import OutgoingCall from './OutgoingCall'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -17,9 +17,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; diff --git a/packages/ui-voip/src/v2/views/OutgoingCallTransfer.stories.tsx b/packages/ui-voip/src/v2/views/OutgoingCallTransfer.stories.tsx index c2a01b54cfd2e..f64fe038b7305 100644 --- a/packages/ui-voip/src/v2/views/OutgoingCallTransfer.stories.tsx +++ b/packages/ui-voip/src/v2/views/OutgoingCallTransfer.stories.tsx @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import OutgoingCallTransfer from './OutgoingCallTransfer'; -import MediaCallProviderMock from '../MockedMediaCallProvider'; +import MockedMediaCallProvider from '../MockedMediaCallProvider'; const mockedContexts = mockAppRoot() .withTranslations('en', 'core', { @@ -18,9 +18,9 @@ export default { decorators: [ mockedContexts, (Story) => ( - + - + ), ], } satisfies Meta; diff --git a/yarn.lock b/yarn.lock index a5a6d0b6dc198..ef60b63ad81ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8474,7 +8474,7 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/desktop-api@workspace:packages/desktop-api, @rocket.chat/desktop-api@workspace:~": +"@rocket.chat/desktop-api@workspace:^, @rocket.chat/desktop-api@workspace:packages/desktop-api, @rocket.chat/desktop-api@workspace:~": version: 0.0.0-use.local resolution: "@rocket.chat/desktop-api@workspace:packages/desktop-api" dependencies: @@ -10601,6 +10601,7 @@ __metadata: "@playwright/test": "npm:^1.52.0" "@react-spectrum/test-utils": "npm:~1.0.0-alpha.8" "@rocket.chat/css-in-js": "npm:~0.31.25" + "@rocket.chat/desktop-api": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/fuselage": "npm:^0.68.1"