diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ce902b70ee484..3329dfa731067 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -911,6 +911,8 @@ "Calendar_settings": "Calendar settings", "Call": "Call", "Call_Already_Ended": "Call Already Ended", + "Call_ID": "Call ID", + "Call_info": "Call info", "Call_Information": "Call Information", "Call_again": "Call again", "Call_back": "Call back", @@ -1803,6 +1805,7 @@ "Duplicate_file_name_found": "Duplicate file name found.", "Duplicate_private_group_name": "A Private Group with name '%s' exists", "Duplicated_Email_address_will_be_ignored": "Duplicated email address will be ignored.", + "Duration": "Duration", "E2EE_Composer_Unencrypted_Message": "You're sending an unencrypted message", "E2EE_password_reset": "E2EE password reset", "E2EE_alert": "Enabling E2EE affects other functionalities ", @@ -2569,6 +2572,7 @@ "Incoming_call_from": "Incoming call from", "Incoming_call_from__roomName__": "Incoming call from {{roomName}}", "Incoming_call_transfer": "Incoming call transfer", + "Incoming_voice_call": "Incoming voice call", "Incoming_voice_call_canceled_suddenly": "An Incoming Voice Call was canceled suddenly.", "Incoming_voice_call_canceled_user_not_registered": "An Incoming Voice Call was canceled due to an unexpected error.", "Industry": "Industry", @@ -3969,6 +3973,7 @@ "Out_of_seats": "Out of Seats", "Outdated": "Outdated", "Outgoing": "Outgoing", + "Outgoing_voice_call": "Outgoing voice call", "Outgoing_WebHook": "Outgoing WebHook", "Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.", "Outlook_Calendar": "Outlook Calendar", @@ -5525,6 +5530,7 @@ "UserData_MessageLimitPerRequest": "Message Limit per Request", "UserData_ProcessingFrequency": "Processing Frequency (Minutes)", "User_Info": "User Info", + "User_info": "User info", "User_Interface": "User Interface", "User_Presence": "User Presence", "User_Settings": "User Settings", diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index 4202640ad8890..a25827fe0a302 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -13,9 +13,9 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "storybook": "storybook dev -p 6006", - "test": "jest", + "test": "yarn testunit", "test-storybook": "npx concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --ci\" \"npx wait-on tcp:127.0.0.1:6006 && yarn exec test-storybook\"", - "testunit": "jest", + "testunit": "TZ=UTC jest", "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" }, "dependencies": { @@ -35,6 +35,7 @@ "@rocket.chat/fuselage": "^0.70.0", "@rocket.chat/fuselage-hooks": "~0.38.1", "@rocket.chat/fuselage-tokens": "~0.33.2", + "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/icons": "~0.46.0", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/mock-providers": "workspace:~", @@ -79,6 +80,7 @@ "@rocket.chat/css-in-js": "*", "@rocket.chat/fuselage": "*", "@rocket.chat/fuselage-hooks": "*", + "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/icons": "*", "@rocket.chat/styled": "*", "@rocket.chat/ui-avatar": "workspace:^", diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index f81a2ded8a160..2f8c0ce602141 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -4,4 +4,7 @@ export { MediaCallContext, useMediaCallExternalContext as useMediaCallContext, t export { useMediaCallAction } from './hooks'; +export { CallHistoryContextualBar } from './views'; +export type { InternalCallHistoryContact, ExternalCallHistoryContact, CallHistoryData } from './views'; + export { getHistoryMessagePayload } from './ui-kit/getHistoryMessagePayload'; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx new file mode 100644 index 0000000000000..be86fed7e4e6e --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx @@ -0,0 +1,58 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; + +import type { HistoryActionCallbacks } from './CallHistoryActions'; +import CallHistoryActions from './CallHistoryActions'; + +const noop = () => undefined; + +const meta = { + title: 'V2/Views/CallHistoryContextualbar/CallHistoryActions', + component: CallHistoryActions, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Options: 'Options', + Voice_call: 'Voice call', + Video_call: 'Video call', + Jump_to_message: 'Jump to message', + Direct_Message: 'Direct Message', + User_info: 'User info', + }) + .withDefaultLanguage('en-US') + .buildStoryDecorator(), + (Story): ReactElement => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const actionList = ['voiceCall', 'videoCall', 'jumpToMessage', 'directMessage', 'userInfo']; + +const getArgs = (index: number) => { + return Object.fromEntries(actionList.slice(0, index).map((action) => [action, noop])) as HistoryActionCallbacks; +}; + +export const Default: Story = { + args: { + onClose: noop, + actions: getArgs(5), + }, +}; + +export const WithLessActions: Story = { + args: { + onClose: noop, + actions: getArgs(3), + }, +}; + +export const WithSingleAction: Story = { + args: { + onClose: noop, + actions: getArgs(1), + }, +}; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.tsx new file mode 100644 index 0000000000000..fce4d09644634 --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.tsx @@ -0,0 +1,56 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import { ContextualbarActions, ContextualbarClose, GenericMenu } from '@rocket.chat/ui-client'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +type HistoryActions = 'voiceCall' | 'videoCall' | 'jumpToMessage' | 'directMessage' | 'userInfo'; + +export type HistoryActionCallbacks = { + [K in HistoryActions]?: () => void; +}; + +type CallHistoryActionsProps = { + onClose: () => void; + actions: HistoryActionCallbacks; +}; + +const iconDictionary: Record = { + voiceCall: 'phone', + videoCall: 'video', + jumpToMessage: 'jump', + directMessage: 'balloon', + userInfo: 'user', +} as const; + +const i18nDictionary: Record = { + voiceCall: 'Voice_call', + videoCall: 'Video_call', + jumpToMessage: 'Jump_to_message', + directMessage: 'Direct_Message', + userInfo: 'User_info', +} as const; + +const getItems = (actions: HistoryActionCallbacks, t: TFunction) => { + return (Object.entries(actions) as [HistoryActions, () => void][]) + .filter(([_, callback]) => callback) + .map(([action, callback]) => ({ + id: action, + icon: iconDictionary[action], + content: t(i18nDictionary[action]), + onClick: callback, + })); +}; + +const CallHistoryActions = ({ onClose, actions }: CallHistoryActionsProps) => { + const { t } = useTranslation(); + + const items = getItems(actions, t); + return ( + + {items.length > 0 && } + + + ); +}; + +export default CallHistoryActions; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.spec.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.spec.tsx new file mode 100644 index 0000000000000..d084e50e6e398 --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.spec.tsx @@ -0,0 +1,20 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as contextualbarStories from './CallHistoryContextualbar.stories'; + +const testCases = Object.values(composeStories(contextualbarStories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.stories.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.stories.tsx new file mode 100644 index 0000000000000..93d09034e6f1c --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.stories.tsx @@ -0,0 +1,88 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; + +import CallHistoryContextualbar from './CallHistoryContextualbar'; + +const noop = () => undefined; + +const meta = { + title: 'V2/Views/CallHistoryContextualbar', + component: CallHistoryContextualbar, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Call_info: 'Call info', + Direct_message: 'Direct message', + Call: 'Call', + Call_ended_bold: '*Voice call ended*', + Incoming_voice_call: 'Incoming voice call', + Outgoing_voice_call: 'Outgoing voice call', + Duration: 'Duration', + Voice_call_extension: 'Voice call extension', + Call_ID: 'Call ID', + Options: 'Options', + Voice_call: 'Voice call', + Video_call: 'Video call', + Jump_to_message: 'Jump to message', + Direct_Message: 'Direct Message', + User_info: 'User info', + }) + .withDefaultLanguage('en-US') + .buildStoryDecorator(), + (Story): ReactElement => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const externalContact = { + number: '1234567890', +}; + +const internalContact = { + _id: '1234567890', + name: 'John Doe', + username: 'john.doe', + voiceCallExtension: '0000', +}; + +export const Default: Story = { + args: { + onClose: noop, + actions: { + voiceCall: noop, + videoCall: noop, + jumpToMessage: noop, + directMessage: noop, + userInfo: noop, + }, + contact: internalContact, + data: { + callId: '1234567890', + direction: 'inbound', + duration: 100, + startedAt: new Date('2025-02-07T12:00:00.000Z'), + state: 'ended', + }, + }, +}; + +export const ExternalContact: Story = { + args: { + onClose: noop, + actions: { + voiceCall: noop, + }, + data: { + callId: '1234567890', + direction: 'inbound', + duration: 100, + startedAt: new Date('2025-02-07T12:00:00.000Z'), + state: 'ended', + }, + contact: externalContact, + }, +}; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.tsx new file mode 100644 index 0000000000000..5f84e6f014fdd --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryContextualbar.tsx @@ -0,0 +1,132 @@ +import { Box, Button, ButtonGroup, Icon, MessageBlock } from '@rocket.chat/fuselage'; +import { UiKitComponent, UiKitMessage as UiKitMessageSurfaceRender, UiKitContext } from '@rocket.chat/fuselage-ui-kit'; +import { + ContextualbarDialog, + ContextualbarHeader, + ContextualbarTitle, + ContextualbarFooter, + ContextualbarIcon, + ContextualbarScrollableContent, + InfoPanel, + InfoPanelSection, + InfoPanelLabel, + InfoPanelText, +} from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import type { HistoryActionCallbacks } from './CallHistoryActions'; +import CallHistoryActions from './CallHistoryActions'; +import CallHistoryExternalUser from './CallHistoryExternalUser'; +import CallHistoryInternalUser from './CallHistoryInternalUser'; +import { useFullStartDate } from './useFullStartDate'; +import { getHistoryMessagePayload } from '../../ui-kit/getHistoryMessagePayload'; + +export type InternalCallHistoryContact = { + _id: string; + name?: string; + username: string; + voiceCallExtension?: string; +}; + +export type ExternalCallHistoryContact = { + number: string; +}; + +export type CallHistoryData = { + callId: string; + direction: 'inbound' | 'outbound'; + duration: number; + startedAt: Date; + state: 'ended' | 'not-answered' | 'failed' | 'error' | 'transferred'; + messageId?: string; +}; + +type CallHistoryContextualBarProps = { + onClose: () => void; + actions: HistoryActionCallbacks; + contact: InternalCallHistoryContact | ExternalCallHistoryContact; + data: CallHistoryData; +}; + +const isInternalCallHistoryContact = ( + contact: InternalCallHistoryContact | ExternalCallHistoryContact, +): contact is InternalCallHistoryContact => { + return '_id' in contact; +}; + +const contextValue = { + action: () => undefined, + rid: '', + values: {}, +}; + +const CallHistoryContextualBar = ({ onClose, actions, contact, data }: CallHistoryContextualBarProps) => { + const { t } = useTranslation(); + + const { voiceCall, directMessage } = actions; + const { duration, callId, direction, startedAt } = data; + + const date = useFullStartDate(startedAt); + return ( + + + + {t('Call_info')} + + + + + + {isInternalCallHistoryContact(contact) ? ( + + ) : ( + + )} + + + + + {direction === 'inbound' ? t('Incoming_voice_call') : t('Outgoing_voice_call')} + + + + + + + + + {date} + + + {t('Call_ID')} + {callId} + + {isInternalCallHistoryContact(contact) && contact.voiceCallExtension && ( + + {t('Voice_call_extension')} + {contact.voiceCallExtension} + + )} + + + + + {isInternalCallHistoryContact(contact) && directMessage && ( + + )} + {voiceCall && ( + + )} + + + + ); +}; + +export default CallHistoryContextualBar; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryExternalUser.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryExternalUser.tsx new file mode 100644 index 0000000000000..9bda24ea09c03 --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryExternalUser.tsx @@ -0,0 +1,21 @@ +import { Box, Icon, FramedIcon } from '@rocket.chat/fuselage'; + +type CallHistoryExternalUserProps = { + number: string; +}; + +const CallHistoryExternalUser = ({ number }: CallHistoryExternalUserProps) => { + return ( + + + + + + + + {number.startsWith('+') ? number : `+${number}`} + + ); +}; + +export default CallHistoryExternalUser; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryInternalUser.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryInternalUser.tsx new file mode 100644 index 0000000000000..20edfebdf1c7b --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryInternalUser.tsx @@ -0,0 +1,36 @@ +import { Box, Icon, Avatar, StatusBullet } from '@rocket.chat/fuselage'; +import { useUserDisplayName } from '@rocket.chat/ui-client'; +import { useUserAvatarPath, useUserPresence, useUserCard } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +type CallHistoryInternalUserProps = { + username: string; + name?: string; + _id: string; +}; + +const CallHistoryInternalUser = ({ username, name, _id }: CallHistoryInternalUserProps) => { + const getUserAvatarPath = useUserAvatarPath(); + + const avatarUrl = useMemo(() => { + return getUserAvatarPath({ username }); + }, [username, getUserAvatarPath]); + + const displayName = useUserDisplayName({ username, name }); + + const userStatus = useUserPresence(_id); + + const { triggerProps, openUserCard } = useUserCard(); + + return ( + openUserCard(e, username)} {...triggerProps}> + {avatarUrl ? : } + + + + {displayName} + + ); +}; + +export default CallHistoryInternalUser; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/__snapshots__/CallHistoryContextualbar.spec.tsx.snap b/packages/ui-voip/src/views/CallHistoryContextualbar/__snapshots__/CallHistoryContextualbar.spec.tsx.snap new file mode 100644 index 0000000000000..85936952ebd7c --- /dev/null +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/__snapshots__/CallHistoryContextualbar.spec.tsx.snap @@ -0,0 +1,604 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+