snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
const VideoConfBlockModal = ({ onClose, onConfirm }: { onClose: () => void; onConfirm: () => void }) => {
const t = useTranslation();
const workspaceUrl = useSetting('Site_Url');
const confirmButtonContent = (
<Box>
<Icon marginInlineEnd={8} size='x20' name='new-window' />
{t('Open_call')}
</Box>
);
const handleConfirm = useCallback(() => {
onConfirm();
onClose();
}, [onClose, onConfirm]);
return (
<GenericModal
open
icon={null}
variant='warning'
title={t('Open_call_in_new_tab')}
confirmText={confirmButtonContent}
onConfirm={handleConfirm}
onCancel={onClose}
onClose={onClose}
>
<>
<Box marginBlockEnd={24}>{t('Your_web_browser_blocked_Rocket_Chat_from_opening_tab')}</Box>
<Box>
{t('To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL')}
<Box is='span' fontWeight={700}>
{workspaceUrl as string}
</Box>
</Box>
</>
</GenericModal>
);
};
export default VideoConfBlockModal;

View File

@@ -0,0 +1,87 @@
import {
Modal,
Button,
Box,
Callout,
Margins,
ModalHeader,
ModalHeaderText,
ModalTagline,
ModalTitle,
ModalClose,
ModalContent,
ModalHeroImage,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type VideoConfConfigModalProps = {
onClose: () => void;
onConfirm?: () => void;
isAdmin: boolean;
};
const VideoConfConfigModal = ({ onClose, onConfirm, isAdmin }: VideoConfConfigModalProps) => {
const { t } = useTranslation();
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTagline>{isAdmin ? t('Missing_configuration') : t('App_not_enabled')}</ModalTagline>
<ModalTitle>{isAdmin ? t('Configure_video_conference') : t('Video_Conference')}</ModalTitle>
</ModalHeaderText>
<ModalClose title={t('Close')} onClick={onClose} />
</ModalHeader>
<ModalContent>
<ModalHeroImage maxHeight='initial' src='/images/conf-call-config.svg' />
<Box fontScale='h3'>{t('Premium_capabilities')}</Box>
<Box withRichContent>
<Box is='ul' paddingInlineStart={24}>
<li>{t('Ringtones_and_visual_indicators_notify_people_of_incoming_calls')}</li>
<li>{t('Call_history_provides_a_record_of_when_calls_took_place_and_who_joined')}</li>
</Box>
</Box>
<Box fontScale='h3'>{t('Conference_call_apps')}</Box>
<Margins blockStart={12}>
<Callout icon='team' title={isAdmin ? t('Jitsi_included_with_Community') : 'Jitsi'}>
{t('Open-source_conference_call_solution')}
</Callout>
<Callout icon='lightning' title={t('Pexip_Premium_only')}>
{t('A_secure_and_highly_private_self-managed_solution_for_conference_calls')}
</Callout>
<Callout icon='lightning' title={t('Google_Meet_Premium_only')}>
{t('Secure_SaaS_solution')} {t('A_cloud-based_platform_for_those_needing_a_plug-and-play_app')}
</Callout>
</Margins>
<Box fontScale='h3' marginBlockStart={24}>
{t('Required_action')}
</Box>
<Callout marginBlockStart={12} marginBlockEnd={24} title={t('Missing_configuration')} type='warning'>
{isAdmin
? t('An_app_needs_to_be_installed_and_configured')
: t('A_workspace_admin_needs_to_install_and_configure_a_conference_call_app')}
</Callout>
</ModalContent>
<ModalFooter justifyContent='space-between'>
<ModalFooterAnnotation>
{isAdmin
? t('Configure_video_conference_to_make_it_available_on_this_workspace')
: t('Talk_to_your_workspace_administrator_about_enabling_video_conferencing')}
</ModalFooterAnnotation>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Close')}</Button>
{onConfirm && isAdmin && (
<Button primary onClick={onConfirm}>
{t('Open_settings')}
</Button>
)}
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default VideoConfConfigModal;

View File

@@ -0,0 +1,90 @@
import type { VideoConference } from '@rocket.chat/core-typings';
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Throbber } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarEmptyContent,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import VideoConfListItem from './VideoConfListItem';
import { getErrorMessage } from '../../../../../lib/errorHandling';
export type VideoConfListProps = {
onClose: () => void;
total: number;
videoConfs: VideoConference[];
loading: boolean;
error?: Error;
reload: () => void;
loadMoreItems: () => void;
};
const VideoConfList = ({ onClose, total, videoConfs, loading, error, reload, loadMoreItems }: VideoConfListProps) => {
const { t } = useTranslation();
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver<HTMLElement>({
debounceDelay: 200,
});
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='phone' />
<ContextualbarTitle>{t('Calls')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarContent paddingInline={0} ref={ref}>
{loading && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{(total === 0 || error) && (
<Box display='flex' flexDirection='column' justifyContent='center' height='100%'>
{error && (
<States>
<StatesIcon name='circle-exclamation' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesSubtitle>{getErrorMessage(error)}</StatesSubtitle>
</States>
)}
{!loading && total === 0 && (
<ContextualbarEmptyContent
icon='phone'
title={t('No_history')}
subtitle={t('There_is_no_video_conference_history_in_this_room')}
/>
)}
</Box>
)}
<Box flexGrow={1} flexShrink={1} overflow='hidden' display='flex'>
{videoConfs.length > 0 && (
<VirtualizedScrollbars>
<Virtuoso
style={{
height: blockSize,
width: inlineSize,
}}
totalCount={total}
endReached={loadMoreItems}
overscan={25}
data={videoConfs}
itemContent={(_index, data) => <VideoConfListItem videoConfData={data} reload={reload} />}
/>
</VirtualizedScrollbars>
)}
</Box>
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default VideoConfList;

View File

@@ -0,0 +1,141 @@
import type { VideoConference } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import {
Button,
Message,
MessageLeftContainer,
MessageContainer,
MessageHeader,
MessageName,
MessageTimestamp,
MessageBody,
MessageBlock,
Box,
Palette,
IconButton,
ButtonGroup,
AvatarStack,
} from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useTranslation } from '@rocket.chat/ui-contexts';
import { useVideoConfJoinCall } from '@rocket.chat/ui-video-conf';
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
import { VIDEOCONF_STACK_MAX_USERS } from '../../../../../lib/constants';
import { useGoToRoom } from '../../../hooks/useGoToRoom';
const VideoConfListItem = ({
videoConfData,
className = [],
reload,
...props
}: {
videoConfData: VideoConference;
className?: string[];
reload: () => void;
}) => {
const t = useTranslation();
const formatDate = useTimeAgo();
const joinCall = useVideoConfJoinCall();
const {
_id: callId,
createdBy: { name, username, _id },
users,
createdAt,
endedAt,
discussionRid,
} = videoConfData;
const displayName = useUserDisplayName({ name, username });
const joinedUsers = users.filter((user) => user._id !== _id);
const hovered = css`
&:hover,
&:focus {
background: ${Palette.surface['surface-tint']};
.rcx-message {
background: ${Palette.surface['surface-tint']};
}
}
`;
const handleJoinConference = useStableCallback((): void => {
joinCall(callId);
return reload();
});
const goToRoom = useGoToRoom();
return (
<Box
color='default'
borderBlockEndWidth={1}
borderBlockEndColor='stroke-extra-light'
borderBlockEndStyle='solid'
className={[...className, hovered].filter(Boolean)}
paddingBlock={8}
>
<Message {...props}>
<MessageLeftContainer>{username && <UserAvatar username={username} size='x36' />}</MessageLeftContainer>
<MessageContainer>
<MessageHeader>
<MessageName title={username}>{displayName}</MessageName>
<MessageTimestamp>{formatDate(createdAt)}</MessageTimestamp>
</MessageHeader>
<MessageBody clamp={2} />
<Box display='flex'></Box>
<MessageBlock flexDirection='row' alignItems='center'>
<ButtonGroup>
<Button disabled={Boolean(endedAt)} small alignItems='center' display='flex' onClick={handleJoinConference}>
{endedAt ? t('Call_ended') : t('Join_call')}
</Button>
{discussionRid && (
<IconButton
small
icon='discussion'
data-drid={discussionRid}
title={t('Join_discussion')}
onClick={() => goToRoom(discussionRid)}
/>
)}
</ButtonGroup>
{joinedUsers.length > 0 && (
<Box marginInlineStart={8} fontScale='c1' display='flex' alignItems='center'>
<AvatarStack>
{joinedUsers.map(
(user, index) =>
user.username &&
index + 1 <= VIDEOCONF_STACK_MAX_USERS && (
<UserAvatar
data-tooltip={user.username}
key={user.username}
username={user.username}
etag={user.avatarETag ?? undefined}
size='x28'
/>
),
)}
</AvatarStack>
<Box marginInlineStart={4}>
{joinedUsers.length > VIDEOCONF_STACK_MAX_USERS
? t('__usersCount__joined', { count: joinedUsers.length - VIDEOCONF_STACK_MAX_USERS })
: t('joined')}
</Box>
</Box>
)}
{joinedUsers.length === 0 && !endedAt && (
<Box marginInlineStart={8} fontScale='c1'>
{t('Be_the_first_to_join')}
</Box>
)}
</MessageBlock>
</MessageContainer>
</Message>
</Box>
);
};
export default VideoConfListItem;

View File

@@ -0,0 +1,25 @@
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import VideoConfList from './VideoConfList';
import { useVideoConfList } from './useVideoConfList';
import { useRoom } from '../../../contexts/RoomContext';
const VideoConfListWithData = () => {
const room = useRoom();
const { closeTab } = useRoomToolbox();
const { isPending, data, error, refetch, fetchNextPage } = useVideoConfList({ roomId: room._id });
return (
<VideoConfList
loading={isPending}
videoConfs={data?.videoConfs ?? []}
total={data?.total ?? 0}
error={error ?? undefined}
reload={refetch}
loadMoreItems={fetchNextPage}
onClose={closeTab}
/>
);
};
export default VideoConfListWithData;

View File

@@ -0,0 +1 @@
export { default } from './VideoConfListWithData';

View File

@@ -0,0 +1,46 @@
import type { IRoom, VideoConference } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { videoConferenceQueryKeys } from '../../../../../lib/queryKeys';
export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => {
const getVideoConfs = useEndpoint('GET', '/v1/video-conference.list');
const count = 25;
return useInfiniteQuery({
queryKey: videoConferenceQueryKeys.fromRoom(roomId),
queryFn: async ({ pageParam: offset }) => {
const { data, total } = await getVideoConfs({
roomId,
offset,
count,
});
return {
items: data.map((videoConf): VideoConference => ({
...videoConf,
_updatedAt: new Date(videoConf._updatedAt),
createdAt: new Date(videoConf.createdAt),
endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined,
users: videoConf.users.map((user) => ({
...user,
ts: new Date(user.ts),
})),
})),
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, _, lastOffset) => {
const nextOffset = lastOffset + count;
if (nextOffset >= lastPage.itemCount) return undefined;
return nextOffset;
},
select: ({ pages }) => ({
videoConfs: pages.flatMap((page) => page.items),
total: pages.at(-1)?.itemCount,
}),
});
};

View File

@@ -0,0 +1,99 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Skeleton } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import {
useVideoConfSetPreferences,
VideoConfPopup,
VideoConfPopupContent,
VideoConfPopupControllers,
VideoConfController,
useVideoConfControllers,
VideoConfButton,
VideoConfPopupFooter,
VideoConfPopupFooterButtons,
VideoConfPopupTitle,
VideoConfPopupHeader,
} from '@rocket.chat/ui-video-conf';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import VideoConfPopupRoomInfo from './VideoConfPopupRoomInfo';
import { useVideoConfRoomName } from '../../hooks/useVideoConfRoomName';
export type IncomingPopupProps = {
id: string;
room: IRoom;
position: number;
onClose: (id: string) => void;
onMute: (id: string) => void;
onConfirm: () => void;
};
const IncomingPopup = ({ id, room, position, onClose, onMute, onConfirm }: IncomingPopupProps) => {
const { t } = useTranslation();
const { controllersConfig, handleToggleMic, handleToggleCam } = useVideoConfControllers();
const setPreferences = useVideoConfSetPreferences();
const roomName = useVideoConfRoomName(room);
const videoConfInfo = useEndpoint('GET', '/v1/video-conference.info');
const { data, isPending, isSuccess } = useQuery({
queryKey: ['getVideoConferenceInfo', id],
queryFn: async () => videoConfInfo({ callId: id }),
});
const showMic = Boolean(data?.capabilities?.mic);
const showCam = Boolean(data?.capabilities?.cam);
const handleJoinCall = useStableCallback(() => {
setPreferences(controllersConfig);
onConfirm();
});
return (
<VideoConfPopup position={position} id={id} aria-label={t('Incoming_call_from__roomName__', { roomName })}>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Incoming_call_from')} />
{isPending && <Skeleton />}
{isSuccess && (showMic || showCam) && (
<VideoConfPopupControllers>
{showCam && (
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
onClick={handleToggleCam}
/>
)}
{showMic && (
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
onClick={handleToggleMic}
/>
)}
</VideoConfPopupControllers>
)}
</VideoConfPopupHeader>
<VideoConfPopupContent>
<VideoConfPopupRoomInfo room={room} />
</VideoConfPopupContent>
<VideoConfPopupFooter>
<VideoConfPopupFooterButtons>
<VideoConfButton primary onClick={handleJoinCall}>
{t('Accept')}
</VideoConfButton>
{onClose && (
<VideoConfButton danger secondary onClick={(): void => onClose(id)}>
{t('Decline')}
</VideoConfButton>
)}
<VideoConfController small={false} secondary title={t('Mute_and_dismiss')} icon='cross' onClick={(): void => onMute(id)} />
</VideoConfPopupFooterButtons>
</VideoConfPopupFooter>
</VideoConfPopup>
);
};
export default IncomingPopup;

View File

@@ -0,0 +1,74 @@
import type { IRoom } from '@rocket.chat/core-typings';
import {
VideoConfPopup,
VideoConfPopupContent,
VideoConfPopupControllers,
VideoConfController,
useVideoConfControllers,
VideoConfButton,
VideoConfPopupFooter,
VideoConfPopupFooterButtons,
VideoConfPopupTitle,
VideoConfPopupHeader,
useVideoConfCapabilities,
useVideoConfPreferences,
} from '@rocket.chat/ui-video-conf';
import { useTranslation } from 'react-i18next';
import VideoConfPopupRoomInfo from './VideoConfPopupRoomInfo';
import { useVideoConfRoomName } from '../../hooks/useVideoConfRoomName';
export type OutgoingPopupProps = {
id: string;
room: IRoom;
onClose: (id: string) => void;
};
const OutgoingPopup = ({ room, onClose, id }: OutgoingPopupProps) => {
const { t } = useTranslation();
const videoConfPreferences = useVideoConfPreferences();
const { controllersConfig } = useVideoConfControllers(videoConfPreferences);
const capabilities = useVideoConfCapabilities();
const roomName = useVideoConfRoomName(room);
const showCam = !!capabilities.cam;
const showMic = !!capabilities.mic;
return (
<VideoConfPopup aria-label={t('Calling__roomName__', { roomName })}>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
{(showCam || showMic) && (
<VideoConfPopupControllers>
{showCam && (
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
)}
{showMic && (
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
)}
</VideoConfPopupControllers>
)}
</VideoConfPopupHeader>
<VideoConfPopupContent>
<VideoConfPopupRoomInfo room={room} />
</VideoConfPopupContent>
<VideoConfPopupFooter>
<VideoConfPopupFooterButtons>
{onClose && <VideoConfButton onClick={(): void => onClose(id)}>{t('Cancel')}</VideoConfButton>}
</VideoConfPopupFooterButtons>
</VideoConfPopupFooter>
</VideoConfPopup>
);
};
export default OutgoingPopup;

View File

@@ -0,0 +1,109 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useOutsideClick, useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
VideoConfPopup,
VideoConfPopupHeader,
VideoConfPopupContent,
VideoConfPopupControllers,
VideoConfController,
useVideoConfControllers,
VideoConfButton,
VideoConfPopupFooter,
VideoConfPopupTitle,
VideoConfPopupFooterButtons,
useVideoConfSetPreferences,
useVideoConfCapabilities,
useVideoConfPreferences,
} from '@rocket.chat/ui-video-conf';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import VideoConfPopupRoomInfo from './VideoConfPopupRoomInfo';
import { useVideoConfRoomName } from '../../hooks/useVideoConfRoomName';
export type StartCallPopupProps = {
id: string;
loading: boolean;
room: IRoom;
onClose: () => void;
onConfirm: () => void;
};
const StartCallPopup = ({ id, loading, room, onClose, onConfirm }: StartCallPopupProps) => {
const { t } = useTranslation();
const ref = useRef<HTMLElement | null>(null);
useOutsideClick([ref], !loading ? onClose : () => undefined);
const setPreferences = useVideoConfSetPreferences();
const videoConfPreferences = useVideoConfPreferences();
const { controllersConfig, handleToggleMic, handleToggleCam } = useVideoConfControllers(videoConfPreferences);
const capabilities = useVideoConfCapabilities();
const roomName = useVideoConfRoomName(room);
const dialogLabel =
room.t === 'd' ? `${t('Start_a_call_with__roomName__', { roomName })}` : `${t('Start_a_call_in__roomName__', { roomName })}`;
const showCam = !!capabilities.cam;
const showMic = !!capabilities.mic;
const handleStartCall = useStableCallback(() => {
setPreferences(controllersConfig);
onConfirm();
});
const callbackRef = useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
}
ref.current = node;
node.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
});
},
[onClose],
);
return (
<VideoConfPopup ref={callbackRef} id={id} aria-label={dialogLabel}>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Start_a_call')} />
{(showCam || showMic) && (
<VideoConfPopupControllers>
{showCam && (
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
onClick={handleToggleCam}
/>
)}
{showMic && (
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
onClick={handleToggleMic}
/>
)}
</VideoConfPopupControllers>
)}
</VideoConfPopupHeader>
<VideoConfPopupContent>
<VideoConfPopupRoomInfo room={room} />
</VideoConfPopupContent>
<VideoConfPopupFooter>
<VideoConfPopupFooterButtons>
<VideoConfButton disabled={loading} primary onClick={handleStartCall}>
{t('Start_call')}
</VideoConfButton>
</VideoConfPopupFooterButtons>
</VideoConfPopupFooter>
</VideoConfPopup>
);
};
export default StartCallPopup;

View File

@@ -0,0 +1,79 @@
import { useFocusManager } from '@react-aria/focus';
import type { IRoom } from '@rocket.chat/core-typings';
import { useUserRoom } from '@rocket.chat/ui-contexts';
import {
useVideoConfAcceptCall,
useVideoConfAbortCall,
useVideoConfRejectIncomingCall,
useVideoConfDismissCall,
useVideoConfStartCall,
useVideoConfDismissOutgoing,
} from '@rocket.chat/ui-video-conf';
import { useEffect, useState } from 'react';
import IncomingPopup from './IncomingPopup';
import OutgoingPopup from './OutgoingPopup';
import StartCallPopup from './StartCallPopup';
export type TimedVideoConfPopupProps = {
id: string;
rid: IRoom['_id'];
isReceiving?: boolean;
isCalling?: boolean;
position: number;
onClose?: (id: string) => void;
};
const TimedVideoConfPopup = ({ id, rid, isReceiving = false, isCalling = false, position }: TimedVideoConfPopupProps) => {
const [starting, setStarting] = useState(false);
const acceptCall = useVideoConfAcceptCall();
const abortCall = useVideoConfAbortCall();
const rejectCall = useVideoConfRejectIncomingCall();
const dismissCall = useVideoConfDismissCall();
const startCall = useVideoConfStartCall();
const dismissOutgoing = useVideoConfDismissOutgoing();
const focusManager = useFocusManager();
const room = useUserRoom(rid);
useEffect(() => {
focusManager?.focusFirst();
}, [focusManager]);
if (!room) {
return null;
}
const handleConfirm = (): void => {
acceptCall(id);
};
const handleClose = (id: string): void => {
if (isReceiving) {
rejectCall(id);
return;
}
abortCall();
};
const handleMute = (): void => {
dismissCall(id);
};
const handleStartCall = async (): Promise<void> => {
setStarting(true);
startCall(rid);
};
if (isReceiving) {
return <IncomingPopup room={room} id={id} position={position} onClose={handleClose} onMute={handleMute} onConfirm={handleConfirm} />;
}
if (isCalling) {
return <OutgoingPopup room={room} id={id} onClose={handleClose} />;
}
return <StartCallPopup loading={starting} room={room} id={id} onClose={dismissOutgoing} onConfirm={handleStartCall} />;
};
export default TimedVideoConfPopup;

View File

@@ -0,0 +1,39 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom, isMultipleDirectMessageRoom } from '@rocket.chat/core-typings';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useUser } from '@rocket.chat/ui-contexts';
import { VideoConfPopupInfo } from '@rocket.chat/ui-video-conf';
import { RoomIcon } from '../../../../../../components/RoomIcon';
import ReactiveUserStatus from '../../../../../../components/UserStatus/ReactiveUserStatus';
import { useVideoConfRoomName } from '../../hooks/useVideoConfRoomName';
export type VideoConfPopupRoomInfoProps = { room: IRoom };
const VideoConfPopupRoomInfo = ({ room }: VideoConfPopupRoomInfoProps) => {
const ownUser = useUser();
const [userId] = room?.uids?.filter((uid) => uid !== ownUser?._id) || [];
const roomName = useVideoConfRoomName(room);
const avatar = <RoomAvatar room={room} size='x40' />;
if (isDirectMessageRoom(room)) {
return (
<VideoConfPopupInfo
avatar={avatar}
{...(userId && {
icon: isMultipleDirectMessageRoom(room) ? <RoomIcon placement='default' room={room} /> : <ReactiveUserStatus uid={userId} />,
})}
>
{roomName}
</VideoConfPopupInfo>
);
}
return (
<VideoConfPopupInfo avatar={avatar} icon={<RoomIcon placement='default' room={room} />}>
{roomName}
</VideoConfPopupInfo>
);
};
export default VideoConfPopupRoomInfo;

View File

@@ -0,0 +1 @@
export { default } from './TimedVideoConfPopup';

View File

@@ -0,0 +1,22 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import VideoConfPopups from './VideoConfPopups';
import { createFakeRoom } from '../../../../../../tests/mocks/data';
import { createFakeVideoConfCall, createFakeIncomingCall } from '../../../../../../tests/mocks/utils/video-conference';
const fakeRoom = createFakeRoom({ t: 'd' });
const fakeDirectVideoConfCall = createFakeVideoConfCall({ type: 'direct', rid: fakeRoom._id });
const fakeIncomingCall = createFakeIncomingCall({ rid: fakeRoom._id });
test('should render video conference incoming popup', async () => {
render(<VideoConfPopups />, {
wrapper: mockAppRoot()
.withRoom(fakeRoom)
.withEndpoint('GET', '/v1/video-conference.info', () => fakeDirectVideoConfCall as any)
.withIncomingCalls([fakeIncomingCall])
.build(),
});
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});

View File

@@ -0,0 +1,67 @@
import { FocusScope } from '@react-aria/focus';
import { useCustomSound } from '@rocket.chat/ui-contexts';
import type { VideoConfPopupPayload } from '@rocket.chat/ui-video-conf';
import {
VideoConfPopupBackdrop,
useVideoConfIsCalling,
useVideoConfIsRinging,
useVideoConfIncomingCalls,
VideoConfPopupSkeleton,
} from '@rocket.chat/ui-video-conf';
import { lazy, Suspense, useEffect, useMemo } from 'react';
import VideoConfPopupPortal from '../../../../../portals/VideoConfPopupPortal';
const VideoConfPopup = lazy(() => import('./VideoConfPopup'));
export type VideoConfPopupsProps = { children?: VideoConfPopupPayload };
const VideoConfPopups = ({ children }: VideoConfPopupsProps) => {
const { callSounds } = useCustomSound();
const incomingCalls = useVideoConfIncomingCalls();
const isRinging = useVideoConfIsRinging();
const isCalling = useVideoConfIsCalling();
const popups = useMemo(
() =>
incomingCalls
.filter((incomingCall) => !incomingCall.dismissed)
.map((incomingCall) => ({ id: incomingCall.callId, rid: incomingCall.rid, isReceiving: true })),
[incomingCalls],
);
useEffect(() => {
if (isRinging) {
callSounds.playRinger();
}
if (isCalling) {
callSounds.playDialer();
}
return (): void => {
callSounds.stopRinger();
callSounds.stopDialer();
};
}, [isRinging, isCalling, callSounds]);
return (
<>
{(children || popups?.length > 0) && (
<VideoConfPopupPortal>
{(children ? [children, ...popups] : popups).map(({ id, rid, isReceiving }, index = 1) => (
<VideoConfPopupBackdrop key={id}>
<Suspense fallback={<VideoConfPopupSkeleton />}>
<FocusScope restoreFocus>
<VideoConfPopup id={id} rid={rid} isReceiving={isReceiving} isCalling={isCalling} position={index * 10} />
</FocusScope>
</Suspense>
</VideoConfPopupBackdrop>
))}
</VideoConfPopupPortal>
)}
</>
);
};
export default VideoConfPopups;

View File

@@ -0,0 +1 @@
export { default } from './VideoConfPopups';

View File

@@ -0,0 +1,85 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { screen, act, renderHook } from '@testing-library/react';
import { useVideoConfOpenCall } from './useVideoConfOpenCall';
describe('with window.RocketChatDesktop set', () => {
beforeEach(() => {
window.RocketChatDesktop = {
openInternalVideoChatWindow: jest.fn(),
} as any;
});
afterAll(() => {
delete window.RocketChatDesktop;
});
it('should pass to videoConfOpenCall the url', async () => {
const { result } = renderHook(() => useVideoConfOpenCall(), { wrapper: mockAppRoot().build() });
const url = faker.internet.url();
act(() => {
result.current(url);
});
expect(window.RocketChatDesktop?.openInternalVideoChatWindow).toHaveBeenCalledWith(url, { providerName: undefined });
});
it('should pass to videoConfOpenCall the url and the providerName', async () => {
const { result } = renderHook(() => useVideoConfOpenCall(), { wrapper: mockAppRoot().build() });
const url = faker.internet.url();
const providerName = faker.lorem.word();
act(() => {
result.current(url, providerName);
});
expect(window.RocketChatDesktop?.openInternalVideoChatWindow).toHaveBeenCalledWith(url, {
providerName,
});
});
});
describe('without window.RocketChatDesktop set', () => {
const previousWindowOpen = window.open;
afterAll(() => {
window.open = previousWindowOpen;
});
it('should open window', async () => {
window.open = jest.fn(() => ({}) as Window);
const { result } = renderHook(() => useVideoConfOpenCall(), {
wrapper: mockAppRoot().build(),
});
const url = faker.internet.url();
act(() => {
result.current(url);
});
expect(window.open).toHaveBeenCalledWith(url);
expect(screen.queryByRole('dialog', { name: 'Open_call_in_new_tab' })).not.toBeInTheDocument();
});
it('should NOT open window, AND open modal instead', async () => {
window.open = jest.fn(() => null);
const { result } = renderHook(() => useVideoConfOpenCall(), {
wrapper: mockAppRoot().build(),
});
const url = faker.internet.url();
act(() => {
result.current(url);
});
expect(window.open).toHaveBeenCalledWith(url);
expect(window.open).toHaveReturnedWith(null);
expect(await screen.findByRole('dialog', { name: 'Open_call_in_new_tab' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,30 @@
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import VideoConfBlockModal from '../VideoConfBlockModal';
export const useVideoConfOpenCall = () => {
const setModal = useSetModal();
const handleOpenCall = useCallback(
(callUrl: string, providerName?: string | undefined) => {
const desktopApp = window.RocketChatDesktop;
if (!desktopApp?.openInternalVideoChatWindow) {
const open = () => window.open(callUrl);
const popup = open();
if (popup !== null) {
return;
}
setModal(<VideoConfBlockModal onClose={(): void => setModal(null)} onConfirm={open} />);
return;
}
desktopApp.openInternalVideoChatWindow(callUrl, { providerName });
},
[setModal],
);
return handleOpenCall;
};

View File

@@ -0,0 +1,11 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useUserSubscription } from '@rocket.chat/ui-contexts';
export const useVideoConfRoomName = (room: IRoom): string | undefined => {
const subscription = useUserSubscription(room._id);
const username = useUserDisplayName({ name: subscription?.fname, username: subscription?.name });
return isDirectMessageRoom(room) ? username : room.fname || room.name;
};

View File

@@ -0,0 +1,24 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useRoute, useRole } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import VideoConfConfigModal from '../VideoConfConfigModal';
export const useVideoConfWarning = (): ((error: unknown) => void) => {
const setModal = useSetModal();
const isAdmin = useRole('admin');
const videoConfSettingsRoute = useRoute('admin-settings');
const handleClose = useStableCallback(() => setModal(null));
const handleRedirectToConfiguration = useStableCallback(() => {
handleClose();
videoConfSettingsRoute.push({
group: 'Video_Conference',
});
});
return useMemo(
() => (): void => setModal(<VideoConfConfigModal onClose={handleClose} onConfirm={handleRedirectToConfiguration} isAdmin={isAdmin} />),
[handleClose, handleRedirectToConfiguration, isAdmin, setModal],
);
};