@stream-io/video-react-sdk 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,10 +10,18 @@ import { DeviceSelectorVideo } from '../DeviceSettings';
10
10
  import { PermissionNotification } from '../Notification';
11
11
  import { useRequestPermission } from '../../hooks';
12
12
  import { Icon } from '../Icon';
13
+ import { WithTooltip } from '../Tooltip';
14
+ import { useState } from 'react';
15
+ import {
16
+ PropsWithErrorHandler,
17
+ createCallControlHandler,
18
+ } from '../../utilities/callControlHandler';
13
19
 
14
- export type ToggleVideoPreviewButtonProps = Pick<
15
- IconButtonWithMenuProps,
16
- 'caption' | 'Menu' | 'menuPlacement'
20
+ export type ToggleVideoPreviewButtonProps = PropsWithErrorHandler<
21
+ Pick<
22
+ IconButtonWithMenuProps,
23
+ 'caption' | 'Menu' | 'menuPlacement' | 'onMenuToggle'
24
+ >
17
25
  >;
18
26
 
19
27
  export const ToggleVideoPreviewButton = (
@@ -28,44 +36,55 @@ export const ToggleVideoPreviewButton = (
28
36
  const { t } = useI18n();
29
37
  const { useCameraState } = useCallStateHooks();
30
38
  const { camera, optimisticIsMute, hasBrowserPermission } = useCameraState();
39
+ const [tooltipDisabled, setTooltipDisabled] = useState(false);
40
+ const handleClick = createCallControlHandler(props, () => camera.toggle());
31
41
 
32
42
  return (
33
- <CompositeButton
34
- active={optimisticIsMute}
35
- caption={caption}
36
- className={clsx(!hasBrowserPermission && 'str-video__device-unavailable')}
43
+ <WithTooltip
37
44
  title={
38
45
  !hasBrowserPermission
39
46
  ? t('Check your browser video permissions')
40
- : caption || t('Video')
41
- }
42
- variant="secondary"
43
- data-testid={
44
- optimisticIsMute
45
- ? 'preview-video-unmute-button'
46
- : 'preview-video-mute-button'
47
+ : caption ?? t('Video')
47
48
  }
48
- onClick={() => camera.toggle()}
49
- disabled={!hasBrowserPermission}
50
- Menu={Menu}
51
- menuPlacement={menuPlacement}
52
- {...restCompositeButtonProps}
49
+ tooltipDisabled={tooltipDisabled}
53
50
  >
54
- <Icon icon={!optimisticIsMute ? 'camera' : 'camera-off'} />
55
- {!hasBrowserPermission && (
56
- <span
57
- className="str-video__no-media-permission"
58
- title={t('Check your browser video permissions')}
59
- children="!"
60
- />
61
- )}
62
- </CompositeButton>
51
+ <CompositeButton
52
+ active={optimisticIsMute}
53
+ caption={caption}
54
+ className={clsx(
55
+ !hasBrowserPermission && 'str-video__device-unavailable',
56
+ )}
57
+ variant="secondary"
58
+ data-testid={
59
+ optimisticIsMute
60
+ ? 'preview-video-unmute-button'
61
+ : 'preview-video-mute-button'
62
+ }
63
+ onClick={handleClick}
64
+ disabled={!hasBrowserPermission}
65
+ Menu={Menu}
66
+ menuPlacement={menuPlacement}
67
+ onMenuToggle={(shown) => setTooltipDisabled(shown)}
68
+ {...restCompositeButtonProps}
69
+ >
70
+ <Icon icon={!optimisticIsMute ? 'camera' : 'camera-off'} />
71
+ {!hasBrowserPermission && (
72
+ <span
73
+ className="str-video__no-media-permission"
74
+ title={t('Check your browser video permissions')}
75
+ children="!"
76
+ />
77
+ )}
78
+ </CompositeButton>
79
+ </WithTooltip>
63
80
  );
64
81
  };
65
82
 
66
- type ToggleVideoPublishingButtonProps = Pick<
67
- IconButtonWithMenuProps,
68
- 'caption' | 'Menu' | 'menuPlacement'
83
+ type ToggleVideoPublishingButtonProps = PropsWithErrorHandler<
84
+ Pick<
85
+ IconButtonWithMenuProps,
86
+ 'caption' | 'Menu' | 'menuPlacement' | 'onMenuToggle'
87
+ >
69
88
  >;
70
89
 
71
90
  export const ToggleVideoPublishingButton = (
@@ -86,6 +105,14 @@ export const ToggleVideoPublishingButton = (
86
105
  const { camera, optimisticIsMute, hasBrowserPermission } = useCameraState();
87
106
  const callSettings = useCallSettings();
88
107
  const isPublishingVideoAllowed = callSettings?.video.enabled;
108
+ const [tooltipDisabled, setTooltipDisabled] = useState(false);
109
+ const handleClick = createCallControlHandler(props, async () => {
110
+ if (!hasPermission) {
111
+ await requestPermission();
112
+ } else {
113
+ await camera.toggle();
114
+ }
115
+ });
89
116
 
90
117
  return (
91
118
  <Restricted requiredGrants={[OwnCapability.SEND_VIDEO]}>
@@ -98,10 +125,7 @@ export const ToggleVideoPublishingButton = (
98
125
  )}
99
126
  messageRevoked={t('You can no longer share your video.')}
100
127
  >
101
- <CompositeButton
102
- active={optimisticIsMute}
103
- caption={caption}
104
- variant="secondary"
128
+ <WithTooltip
105
129
  title={
106
130
  !hasPermission
107
131
  ? t('You have no permission to share your video')
@@ -111,31 +135,35 @@ export const ToggleVideoPublishingButton = (
111
135
  ? t('Video publishing is disabled by the system')
112
136
  : caption || t('Video')
113
137
  }
114
- disabled={
115
- !hasBrowserPermission || !hasPermission || !isPublishingVideoAllowed
116
- }
117
- data-testid={
118
- optimisticIsMute ? 'video-unmute-button' : 'video-mute-button'
119
- }
120
- onClick={async () => {
121
- if (!hasPermission) {
122
- await requestPermission();
123
- } else {
124
- await camera.toggle();
125
- }
126
- }}
127
- Menu={Menu}
128
- menuPlacement={menuPlacement}
129
- menuOffset={16}
130
- {...restCompositeButtonProps}
138
+ tooltipDisabled={tooltipDisabled}
131
139
  >
132
- <Icon icon={optimisticIsMute ? 'camera-off' : 'camera'} />
133
- {(!hasBrowserPermission ||
134
- !hasPermission ||
135
- !isPublishingVideoAllowed) && (
136
- <span className="str-video__no-media-permission">!</span>
137
- )}
138
- </CompositeButton>
140
+ <CompositeButton
141
+ active={optimisticIsMute}
142
+ caption={caption}
143
+ variant="secondary"
144
+ disabled={
145
+ !hasBrowserPermission ||
146
+ !hasPermission ||
147
+ !isPublishingVideoAllowed
148
+ }
149
+ data-testid={
150
+ optimisticIsMute ? 'video-unmute-button' : 'video-mute-button'
151
+ }
152
+ onClick={handleClick}
153
+ Menu={Menu}
154
+ menuPlacement={menuPlacement}
155
+ menuOffset={16}
156
+ onMenuToggle={(shown) => setTooltipDisabled(shown)}
157
+ {...restCompositeButtonProps}
158
+ >
159
+ <Icon icon={optimisticIsMute ? 'camera-off' : 'camera'} />
160
+ {(!hasBrowserPermission ||
161
+ !hasPermission ||
162
+ !isPublishingVideoAllowed) && (
163
+ <span className="str-video__no-media-permission">!</span>
164
+ )}
165
+ </CompositeButton>
166
+ </WithTooltip>
139
167
  </PermissionNotification>
140
168
  </Restricted>
141
169
  );
@@ -2,6 +2,7 @@ import clsx from 'clsx';
2
2
  import { ChangeEventHandler, useCallback } from 'react';
3
3
 
4
4
  import { DropDownSelect, DropDownSelectOption } from '../DropdownSelect';
5
+ import { useMenuContext } from '../Menu';
5
6
 
6
7
  type DeviceSelectorOptionProps = {
7
8
  id: string;
@@ -64,6 +65,8 @@ const DeviceSelectorList = (props: {
64
65
  onChange,
65
66
  } = props;
66
67
 
68
+ const { close } = useMenuContext();
69
+
67
70
  // sometimes the browser (Chrome) will report the system-default device
68
71
  // with an id of 'default'. In case when it doesn't, we'll select the first
69
72
  // available device.
@@ -100,6 +103,7 @@ const DeviceSelectorList = (props: {
100
103
  key={device.deviceId}
101
104
  onChange={(e) => {
102
105
  onChange?.(e.target.value);
106
+ close?.();
103
107
  }}
104
108
  name={type}
105
109
  selected={
@@ -6,6 +6,7 @@ import {
6
6
  useContext,
7
7
  useEffect,
8
8
  useMemo,
9
+ useRef,
9
10
  useState,
10
11
  } from 'react';
11
12
  import {
@@ -34,6 +35,7 @@ export type MenuToggleProps<E extends HTMLElement> = PropsWithChildren<{
34
35
  strategy?: Strategy;
35
36
  offset?: number;
36
37
  visualType?: MenuVisualType;
38
+ onToggle?: (menuShown: boolean) => void;
37
39
  }>;
38
40
 
39
41
  export type MenuContextValue = {
@@ -84,8 +86,11 @@ export const MenuToggle = <E extends HTMLElement>({
84
86
  offset,
85
87
  visualType = MenuVisualType.MENU,
86
88
  children,
89
+ onToggle,
87
90
  }: MenuToggleProps<E>) => {
88
91
  const [menuShown, setMenuShown] = useState(false);
92
+ const toggleHandler = useRef(onToggle);
93
+ toggleHandler.current = onToggle;
89
94
 
90
95
  const { floating, domReference, refs, x, y } = useFloatingUIPreset({
91
96
  placement,
@@ -97,8 +102,10 @@ export const MenuToggle = <E extends HTMLElement>({
97
102
  const handleClick = (event: MouseEvent) => {
98
103
  if (!floating && domReference?.contains(event.target as Node)) {
99
104
  setMenuShown(true);
105
+ toggleHandler.current?.(true);
100
106
  } else if (floating && !floating?.contains(event.target as Node)) {
101
107
  setMenuShown(false);
108
+ toggleHandler.current?.(false);
102
109
  }
103
110
  };
104
111
 
@@ -109,6 +116,7 @@ export const MenuToggle = <E extends HTMLElement>({
109
116
  !event.ctrlKey
110
117
  ) {
111
118
  setMenuShown(false);
119
+ toggleHandler.current?.(false);
112
120
  }
113
121
  };
114
122
  document?.addEventListener('click', handleClick, { capture: true });
@@ -135,6 +143,7 @@ export const MenuToggle = <E extends HTMLElement>({
135
143
  left: x ?? 0,
136
144
  overflowY: 'auto',
137
145
  }}
146
+ role="menu"
138
147
  children={children}
139
148
  />
140
149
  ) : null}
@@ -3,13 +3,16 @@ import { Tooltip, TooltipProps } from './Tooltip';
3
3
  import { useEnterLeaveHandlers } from './hooks';
4
4
 
5
5
  type WithPopupProps = ComponentProps<'div'> &
6
- Omit<TooltipProps<HTMLDivElement>, 'referenceElement'>;
6
+ Omit<TooltipProps<HTMLDivElement>, 'referenceElement' | 'children'> & {
7
+ tooltipDisabled?: boolean;
8
+ };
7
9
 
8
10
  // todo: duplicate of CallParticipantList.tsx#MediaIndicator - refactor to a single component
9
11
  export const WithTooltip = ({
10
12
  title,
11
13
  tooltipClassName,
12
14
  tooltipPlacement,
15
+ tooltipDisabled,
13
16
  ...props
14
17
  }: WithPopupProps) => {
15
18
  const { handleMouseEnter, handleMouseLeave, tooltipVisible } =
@@ -17,12 +20,14 @@ export const WithTooltip = ({
17
20
  const [tooltipAnchor, setTooltipAnchor] = useState<HTMLDivElement | null>(
18
21
  null,
19
22
  );
23
+ const tooltipActuallyVisible =
24
+ !tooltipDisabled && Boolean(title) && tooltipVisible;
20
25
 
21
26
  return (
22
27
  <>
23
28
  <Tooltip
24
29
  referenceElement={tooltipAnchor}
25
- visible={tooltipVisible}
30
+ visible={tooltipActuallyVisible}
26
31
  tooltipClassName={tooltipClassName}
27
32
  tooltipPlacement={tooltipPlacement}
28
33
  >
@@ -0,0 +1,43 @@
1
+ import { getLogger } from '@stream-io/video-client';
2
+
3
+ export type PropsWithErrorHandler<T = unknown> = T & {
4
+ /**
5
+ * Will be called if the call control action failed with an error (e.g. user didn't grant a
6
+ * browser permission to enable a media device). If no callback is provided, just logs the error.
7
+ * @param error Exception which caused call control action to fail
8
+ */
9
+ onError?: (error: unknown) => void;
10
+ };
11
+
12
+ /**
13
+ * Wraps an event handler, silencing and logging exceptions (excluding the NotAllowedError
14
+ * DOMException, which is a normal situation handled by the SDK)
15
+ *
16
+ * @param props component props, including the onError callback
17
+ * @param handler event handler to wrap
18
+ */
19
+ export const createCallControlHandler = (
20
+ props: PropsWithErrorHandler,
21
+ handler: () => Promise<void>,
22
+ ): (() => Promise<void>) => {
23
+ const logger = getLogger(['react-sdk']);
24
+
25
+ return async () => {
26
+ try {
27
+ await handler();
28
+ } catch (error) {
29
+ if (props.onError) {
30
+ props.onError(error);
31
+ return;
32
+ }
33
+
34
+ if (!isNotAllowedError(error)) {
35
+ logger('error', 'Call control handler failed', error);
36
+ }
37
+ }
38
+ };
39
+ };
40
+
41
+ function isNotAllowedError(error: unknown): error is DOMException {
42
+ return error instanceof DOMException && error.name === 'NotAllowedError';
43
+ }