communication-react-19
Version:
React library for building modern communication user experiences utilizing Azure Communication Services (React 19 compatible fork)
227 lines • 15.1 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { DirectionalHint, Icon, IconButton, mergeStyles, Persona, Stack, Text } from '@fluentui/react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useIdentifiers } from '../identifiers';
import { useLocale } from '../localization';
import { useTheme } from '../theming';
import { RaisedHandIcon } from './assets/RaisedHandIcon';
import { disabledVideoHint, displayNameStyle, iconContainerStyle, overlayContainerStyles, rootStyles, videoContainerStyles, tileInfoContainerStyle, participantStateStringStyles, videoTileHighContrastStyles, iconsGroupContainerStyle } from './styles/VideoTile.styles';
import { pinIconStyle } from './styles/VideoTile.styles';
import useLongPress from './utils/useLongPress';
import { moreButtonStyles } from './styles/VideoTile.styles';
import { raiseHandContainerStyles } from './styles/VideoTile.styles';
import { formatMoreButtonAriaDescription } from './utils';
// Coin max size is set to PersonaSize.size100
const DEFAULT_PERSONA_MAX_SIZE_PX = 100;
// Coin min size is set PersonaSize.size32
const DEFAULT_PERSONA_MIN_SIZE_PX = 32;
const DefaultPlaceholder = (props) => {
const { text, noVideoAvailableAriaLabel, coinSize, hidePersonaDetails } = props;
return (React.createElement(Stack, { className: mergeStyles({ position: 'absolute', height: '100%', width: '100%' }) },
React.createElement(Stack, { styles: defaultPersonaStyles }, coinSize && (React.createElement(Persona, { coinSize: coinSize, hidePersonaDetails: hidePersonaDetails, text: text !== null && text !== void 0 ? text : '', initialsTextColor: "white", "aria-label": noVideoAvailableAriaLabel !== null && noVideoAvailableAriaLabel !== void 0 ? noVideoAvailableAriaLabel : '', showOverflowTooltip: false })))));
};
const defaultPersonaStyles = { root: { margin: 'auto', maxHeight: '100%' } };
const videoTileMoreMenuIconProps = { iconName: undefined, style: { display: 'none' } };
const videoTileMoreMenuProps = {
directionalHint: DirectionalHint.topLeftEdge,
isBeakVisible: false,
styles: { container: { maxWidth: '8rem' } }
};
const VideoTileMoreOptionsButton = (props) => {
const locale = useLocale().strings.videoTile;
const theme = useTheme();
const { contextualMenu, canShowContextMenuButton, participantDisplayName, participantHandRaised, participantIsSpeaking, participantState, participantIsMuted, isMicDisabled, isCameraDisabled } = props;
const [moreButtonAiraDescription, setMoreButtonAriaDescription] = useState('');
useEffect(() => {
setMoreButtonAriaDescription(formatMoreButtonAriaDescription(participantDisplayName, participantIsMuted, participantHandRaised, participantState, participantIsSpeaking, locale, isMicDisabled, isCameraDisabled));
}, [
participantDisplayName,
participantHandRaised,
participantIsMuted,
participantIsSpeaking,
participantState,
locale,
isMicDisabled,
isCameraDisabled
]);
if (!contextualMenu) {
return React.createElement(React.Fragment, null);
}
const optionsIcon = canShowContextMenuButton ? 'VideoTileMoreOptions' : undefined;
return (React.createElement(IconButton, { "data-ui-id": "video-tile-more-options-button", ariaLabel: moreButtonAiraDescription, styles: moreButtonStyles(theme), menuIconProps: videoTileMoreMenuIconProps, menuProps: Object.assign(Object.assign({}, videoTileMoreMenuProps), contextualMenu), iconProps: { iconName: optionsIcon } }));
};
/**
* A component to render the video stream for a single call participant.
*
* Use with {@link GridLayout} in a {@link VideoGallery}.
*
* @public
*/
export const VideoTile = (props) => {
const { children, displayName, initialsName, isMirrored, isMuted, isSpotlighted, isPinned, onRenderPlaceholder, renderElement, overlay: reactionOverlay, showLabel = true, showMuteIndicator = true, styles, userId, noVideoAvailableAriaLabel, isSpeaking, raisedHand, personaMinSize = DEFAULT_PERSONA_MIN_SIZE_PX, personaMaxSize = DEFAULT_PERSONA_MAX_SIZE_PX, contextualMenu, mediaAccess } = props;
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
// need to set a default otherwise the resizeObserver will get stuck in an infinite loop.
const [personaSize, setPersonaSize] = useState(1);
const videoTileRef = useRef(null);
const locale = useLocale();
const theme = useTheme();
const callingPalette = theme.callingPalette;
const isVideoRendered = !!renderElement;
const observer = useRef(new ResizeObserver((entries) => {
if (!entries[0]) {
return;
}
const { width, height } = entries[0].contentRect;
const personaCalcSize = Math.min(width, height) / 3;
// we only want to set the persona size if it has changed
if (personaCalcSize !== personaSize) {
setPersonaSize(Math.max(Math.min(personaCalcSize, personaMaxSize), personaMinSize));
}
}));
useLayoutEffect(() => {
if (videoTileRef.current) {
observer.current.observe(videoTileRef.current);
}
const currentObserver = observer.current;
return () => currentObserver.disconnect();
}, [videoTileRef]);
// TODO: Remove after calling sdk fix the keybaord focus
useEffect(() => {
var _a;
// PPTLive stream id is null
if ((_a = videoTileRef.current) === null || _a === void 0 ? void 0 : _a.id) {
return;
}
let observer;
if (videoTileRef.current) {
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const iframe = document.querySelector('iframe');
if (iframe) {
if (!iframe.getAttribute('tabIndex')) {
iframe.setAttribute('tabIndex', '-1');
}
}
}
}
});
observer.observe(videoTileRef.current, { childList: true, subtree: true });
}
return () => {
observer === null || observer === void 0 ? void 0 : observer.disconnect();
};
}, [displayName, renderElement]);
const useLongPressProps = useMemo(() => {
return {
onLongPress: () => {
var _a;
(_a = props.onLongTouch) === null || _a === void 0 ? void 0 : _a.call(props);
},
touchEventsOnly: true
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onLongTouch]);
const longPressHandlers = useLongPress(useLongPressProps);
const hoverHandlers = useMemo(() => {
return {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
onFocus: () => setIsFocused(true),
onBlur: () => setIsFocused(false)
};
}, []);
const placeholderOptions = {
userId,
text: initialsName !== null && initialsName !== void 0 ? initialsName : displayName,
noVideoAvailableAriaLabel,
coinSize: personaSize,
styles: defaultPersonaStyles,
hidePersonaDetails: true
};
const videoHintWithBorderRadius = mergeStyles(disabledVideoHint, {
borderRadius: theme.effects.roundedCorner4,
backgroundColor: callingPalette.videoTileLabelBackgroundLight
});
const tileInfoStyle = useMemo(() => mergeStyles(isVideoRendered || props.alwaysShowLabelBackground ? videoHintWithBorderRadius : disabledVideoHint, styles === null || styles === void 0 ? void 0 : styles.displayNameContainer), [isVideoRendered, videoHintWithBorderRadius, styles === null || styles === void 0 ? void 0 : styles.displayNameContainer, props.alwaysShowLabelBackground]);
const ids = useIdentifiers();
const canShowLabel = showLabel && (displayName || (showMuteIndicator && isMuted));
const participantStateString = getParticipantStateString(props, locale);
const canShowContextMenuButton = isHovered || isFocused;
let raisedHandBackgroundColor = '';
raisedHandBackgroundColor = callingPalette.raiseHandGold;
const participantMediaAccessIcons = useMemo(() => canShowLabel || participantStateString ? getMediaAccessIcons(showMuteIndicator, isMuted, mediaAccess) : undefined, [canShowLabel, isMuted, mediaAccess, participantStateString, showMuteIndicator]);
const canShowParticipantIcons = participantMediaAccessIcons || isSpotlighted || isPinned;
return (React.createElement(Stack, Object.assign({ "data-ui-id": ids.videoTile, className: mergeStyles(rootStyles, {
background: theme.palette.neutralLighter,
borderRadius: theme.effects.roundedCorner4
}, isSpeaking || raisedHand
? {
'&::after': {
content: `''`,
position: 'absolute',
border: `0.25rem solid ${isSpeaking ? theme.palette.themePrimary : raisedHandBackgroundColor}`,
borderRadius: theme.effects.roundedCorner4,
width: '100%',
height: '100%',
pointerEvents: 'none'
}
}
: {}, videoTileHighContrastStyles(theme), styles === null || styles === void 0 ? void 0 : styles.root) }, longPressHandlers),
React.createElement("div", Object.assign({ ref: videoTileRef, style: { width: '100%', height: '100%' } }, hoverHandlers, { "data-is-focusable": true }),
isVideoRendered ? (React.createElement(Stack, { className: mergeStyles(videoContainerStyles, isMirrored && { transform: 'scaleX(-1)' }, styles === null || styles === void 0 ? void 0 : styles.videoContainer) }, renderElement)) : (React.createElement(Stack, { className: mergeStyles(videoContainerStyles, {
opacity: participantStateString || props.participantState === 'Idle' ? 0.4 : 1
}) }, onRenderPlaceholder ? (onRenderPlaceholder(userId !== null && userId !== void 0 ? userId : '', placeholderOptions, DefaultPlaceholder)) : (React.createElement(DefaultPlaceholder, Object.assign({}, placeholderOptions))))),
reactionOverlay,
(canShowLabel || participantStateString) && (React.createElement(Stack, { horizontal: true, className: tileInfoContainerStyle, tokens: tileInfoContainerTokens },
React.createElement(Stack, { horizontal: true, className: tileInfoStyle },
canShowLabel && (React.createElement(Text, { className: mergeStyles(displayNameStyle), title: displayName, style: { color: participantStateString ? theme.palette.neutralSecondary : 'inherit' }, "data-ui-id": "video-tile-display-name" }, displayName)),
participantStateString && (React.createElement(Text, { className: mergeStyles(participantStateStringStyles(theme)) }, bracketedParticipantString(participantStateString, !!canShowLabel))),
canShowParticipantIcons && (React.createElement(Stack, { horizontal: true, className: mergeStyles(iconsGroupContainerStyle) },
participantMediaAccessIcons,
isSpotlighted && (React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTileSpotlighted" }))),
isPinned && (React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTilePinned", className: mergeStyles(pinIconStyle) }))))),
React.createElement(VideoTileMoreOptionsButton, { contextualMenu: contextualMenu, participantDisplayName: displayName, participantHandRaised: !!raisedHand, participantIsMuted: isMuted, participantState: participantStateString, participantIsSpeaking: isSpeaking, canShowContextMenuButton: canShowContextMenuButton, isMicDisabled: (mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isAudioPermitted) === false, isCameraDisabled: (mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isVideoPermitted) === false })))),
children && (React.createElement(Stack, { className: mergeStyles(overlayContainerStyles, styles === null || styles === void 0 ? void 0 : styles.overlayContainer) }, children)),
raisedHand && (React.createElement(Stack, { horizontal: true, tokens: { childrenGap: '0.2rem' }, className: raiseHandContainerStyles(theme, !canShowLabel) },
React.createElement(Stack.Item, null,
React.createElement(Text, null, raisedHand.raisedHandOrderPosition)),
React.createElement(Stack.Item, null,
React.createElement(RaisedHandIcon, null)))))));
};
const getMediaAccessIcons = (showMuteIndicator, isMuted, mediaAccess) => {
const cameraForbidIcon = mediaAccess && !(mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isVideoPermitted) ? (React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "ControlButtonCameraProhibitedSmall" }))) : undefined;
const micOffIcon = (mediaAccess ? mediaAccess.isAudioPermitted : true) && showMuteIndicator && isMuted ? (React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTileMicOff" }))) : undefined;
const micForbidIcon = mediaAccess && !(mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isAudioPermitted) && showMuteIndicator ? (React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "ControlButtonMicProhibitedSmall" }))) : undefined;
if (!(cameraForbidIcon || micOffIcon || micForbidIcon)) {
return undefined;
}
return (React.createElement(React.Fragment, null,
cameraForbidIcon,
micOffIcon,
micForbidIcon));
};
const getParticipantStateString = (props, locale) => {
const strings = Object.assign(Object.assign({}, locale.strings.videoTile), props.strings);
return props.participantState === 'EarlyMedia' || props.participantState === 'Ringing'
? strings === null || strings === void 0 ? void 0 : strings.participantStateRinging
: props.participantState === 'Hold'
? strings === null || strings === void 0 ? void 0 : strings.participantStateHold
: undefined;
};
const tileInfoContainerTokens = {
// A horizontal Stack sets the left margin to 0 for all it's children.
// We need to allow the children to set their own margins
childrenGap: 'none'
};
const bracketedParticipantString = (participantString, withBrackets) => {
return withBrackets ? `(${participantString})` : participantString;
};
//# sourceMappingURL=VideoTile.js.map