feat: Add caller and ringer to video conf calls (#5046)

* add expo camera and use camera on call init action sheet

* fix permissions

* set colors when calling

* update @react-native-community/hooks lib

* move to useWindowDimensions

* create action to handle video-conf calls

* create videoConf reducer

* add typed-redux-saga lib

* fix return

* change videoConf saga to TS

* fix TS target

* update action and types

* create actionSheetRef

* add notifyUser api

* export video conf types

* add action prop

* use new reducer prop

* add videoConferenceCancel and add allowRinging to videoConferenceStart

* temp-patch

* add locales

* add handler to videoconf message

* fix rest types

* add message types

* path to remove component from dom

* remove notification when is videoconf

* create sound hook

* create dots loader

* update call translation

* the end is near

* move to confirmed

* better code reading

* fix call type

* fix tests

* update podfile

* wip

* fix call order

* move colors

* move to jsx

* fix colors

* add pt-br

* remove patch and point

* fix colors

* fix expo camera

* move to style

* remove unused styles

* update types and style

* wip

* rename IncomingCallComponent

* add custom notification

* wip

* fix naming

* fix styles

* fix import

* fix styles

* change colors

* fixa ringing

* fix import

* organize

* fix sizes

* use realName

* fix spacing

* fix icon size

* fix header gap

* changeColor

* fix safeArea

* set calling only on direct calls

* change ringer to be a component

* cancel call on swipe

* remove join on direct calls

* add props

* update package
This commit is contained in:
Gleidson Daniel Silva 2023-07-03 21:03:39 -03:00 committed by GitHub
parent fea4f164d5
commit 223550d88c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1245 additions and 236 deletions

View File

@ -49,5 +49,9 @@ allprojects {
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}
maven {
// expo-camera bundles a custom com.google.android:cameraview
url "$rootDir/../node_modules/expo-camera/android/maven"
}
}
}

View File

@ -84,3 +84,13 @@ export const ENCRYPTION = createRequestTypes('ENCRYPTION', ['INIT', 'STOP', 'DEC
export const PERMISSIONS = createRequestTypes('PERMISSIONS', ['SET', 'UPDATE']);
export const ROLES = createRequestTypes('ROLES', ['SET', 'UPDATE', 'REMOVE']);
export const VIDEO_CONF = createRequestTypes('VIDEO_CONF', [
'HANDLE_INCOMING_WEBSOCKET_MESSAGES',
'SET',
'REMOVE',
'CLEAR',
'INIT_CALL',
'CANCEL_CALL',
'ACCEPT_CALL',
'SET_CALLING'
]);

81
app/actions/videoConf.ts Normal file
View File

@ -0,0 +1,81 @@
import { Action } from 'redux';
import { ICallInfo } from '../reducers/videoConf';
import { VIDEO_CONF } from './actionsTypes';
interface IHandleVideoConfIncomingWebsocketMessages extends Action {
data: any;
}
export type TCallProps = { mic: boolean; cam: boolean; direct: boolean; rid: string; uid: string };
type TInitCallAction = Action & { payload: TCallProps };
type TSetCallingAction = Action & { payload: boolean };
type TCancelCallAction = Action & { payload: { callId?: string } };
type TAcceptCallAction = Action & { payload: { callId: string } };
export interface IVideoConfGenericAction extends Action {
payload: ICallInfo;
}
export type TActionVideoConf = IHandleVideoConfIncomingWebsocketMessages &
IVideoConfGenericAction &
TSetCallingAction &
Action &
TInitCallAction &
TCancelCallAction &
TAcceptCallAction;
export function handleVideoConfIncomingWebsocketMessages(data: any): IHandleVideoConfIncomingWebsocketMessages {
return {
type: VIDEO_CONF.HANDLE_INCOMING_WEBSOCKET_MESSAGES,
data
};
}
export function setVideoConfCall(payload: ICallInfo): IVideoConfGenericAction {
return {
type: VIDEO_CONF.SET,
payload
};
}
export function removeVideoConfCall(payload: ICallInfo): IVideoConfGenericAction {
return {
type: VIDEO_CONF.REMOVE,
payload
};
}
export function clearVideoConfCalls(): Action {
return {
type: VIDEO_CONF.CLEAR
};
}
export function initVideoCall(payload: TCallProps): TInitCallAction {
return {
type: VIDEO_CONF.INIT_CALL,
payload
};
}
export function cancelCall(payload: { callId?: string }): TCancelCallAction {
return {
type: VIDEO_CONF.CANCEL_CALL,
payload
};
}
export function acceptCall(payload: { callId: string }): TAcceptCallAction {
return {
type: VIDEO_CONF.ACCEPT_CALL,
payload
};
}
export function setCalling(payload: boolean): TSetCallingAction {
return {
type: VIDEO_CONF.SET_CALLING,
payload
};
}

View File

@ -1,5 +1,5 @@
import hoistNonReactStatics from 'hoist-non-react-statics';
import React, { ForwardedRef, forwardRef, useContext, useRef } from 'react';
import React, { createRef, ForwardedRef, forwardRef, useContext } from 'react';
import { TIconsName } from '../CustomIcon';
import ActionSheet from './ActionSheet';
@ -47,23 +47,27 @@ export const withActionSheet = (Component: React.ComponentType<any>): typeof Com
return WithActionSheetComponent;
};
export const ActionSheetProvider = React.memo(({ children }: { children: React.ReactElement | React.ReactElement[] }) => {
const ref: ForwardedRef<IActionSheetProvider> = useRef(null);
const actionSheetRef: React.Ref<IActionSheetProvider> = createRef();
export const ActionSheetProvider = React.memo(({ children }: { children: React.ReactElement | React.ReactElement[] }) => {
const getContext = () => ({
showActionSheet: (options: TActionSheetOptions) => {
ref.current?.showActionSheet(options);
actionSheetRef.current?.showActionSheet(options);
},
hideActionSheet: () => {
ref.current?.hideActionSheet();
actionSheetRef.current?.hideActionSheet();
}
});
return (
<Provider value={getContext()}>
<ActionSheet ref={ref}>
<ActionSheet ref={actionSheetRef}>
<>{children}</>
</ActionSheet>
</Provider>
);
});
export const hideActionSheetRef = (): void => {
actionSheetRef?.current?.hideActionSheet();
};

View File

@ -0,0 +1,107 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import { useAppSelector } from '../lib/hooks';
import { useTheme } from '../theme';
import sharedStyles from '../views/Styles';
import { CustomIcon } from './CustomIcon';
import { BUTTON_HIT_SLOP } from './message/utils';
import AvatarContainer from './Avatar';
import StatusContainer from './Status';
import DotsLoader from './DotsLoader';
type TCallHeader = {
mic: boolean;
cam: boolean;
setCam: Function;
setMic: Function;
title: string;
avatar: string;
uid: string;
name: string;
direct: boolean;
};
export const CallHeader = ({ mic, cam, setCam, setMic, title, avatar, uid, name, direct }: TCallHeader): React.ReactElement => {
const style = useStyle();
const { colors } = useTheme();
const calling = useAppSelector(state => state.videoConf.calling);
const handleColors = (enabled: boolean) => {
if (calling) {
if (enabled) return { button: colors.conferenceCallCallBackButton, icon: colors.gray300 };
return { button: 'transparent', icon: colors.gray100 };
}
if (enabled) return { button: colors.conferenceCallEnabledIconBackground, icon: colors.conferenceCallEnabledIcon };
return { button: 'transparent', icon: colors.conferenceCallDisabledIcon };
};
return (
<View>
<View style={style.actionSheetHeader}>
<View style={style.rowContainer}>
<Text style={style.actionSheetHeaderTitle}>{title}</Text>
{calling && direct ? <DotsLoader /> : null}
</View>
<View style={style.actionSheetHeaderButtons}>
<Touchable
onPress={() => setCam(!cam)}
style={[style.iconCallContainerRight, { backgroundColor: handleColors(cam).button }]}
hitSlop={BUTTON_HIT_SLOP}
disabled={calling}
>
<CustomIcon name={cam ? 'camera' : 'camera-disabled'} size={24} color={handleColors(cam).icon} />
</Touchable>
<Touchable
onPress={() => setMic(!mic)}
style={[style.iconCallContainer, { backgroundColor: handleColors(mic).button }]}
hitSlop={BUTTON_HIT_SLOP}
disabled={calling}
>
<CustomIcon name={mic ? 'microphone' : 'microphone-disabled'} size={24} color={handleColors(mic).icon} />
</Touchable>
</View>
</View>
<View style={style.actionSheetUsernameContainer}>
<AvatarContainer text={avatar} size={36} />
{direct ? <StatusContainer size={16} id={uid} style={style.statusContainerMargin} /> : null}
<Text style={{ ...style.actionSheetUsername, marginLeft: !direct ? 8 : 0 }} numberOfLines={1}>
{name}
</Text>
</View>
</View>
);
};
function useStyle() {
const { colors } = useTheme();
const style = StyleSheet.create({
actionSheetHeader: { flexDirection: 'row', alignItems: 'center' },
actionSheetHeaderTitle: {
fontSize: 14,
...sharedStyles.textBold,
color: colors.n900
},
actionSheetHeaderButtons: { flex: 1, alignItems: 'center', flexDirection: 'row', justifyContent: 'flex-end' },
iconCallContainer: {
padding: 6,
borderRadius: 4
},
iconCallContainerRight: {
padding: 6,
borderRadius: 4,
marginRight: 6
},
actionSheetUsernameContainer: { flexDirection: 'row', paddingTop: 8, alignItems: 'center' },
actionSheetUsername: {
fontSize: 16,
...sharedStyles.textBold,
color: colors.passcodePrimary,
flexShrink: 1
},
rowContainer: { flexDirection: 'row' },
statusContainerMargin: { marginLeft: 8, marginRight: 6 }
});
return style;
}

View File

@ -0,0 +1,65 @@
import React, { useEffect, useState } from 'react';
import { StyleProp, View, ViewStyle, StyleSheet } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useTheme } from '../../theme';
const SIZE = 8;
const MARGIN = 4;
const dots = [1, 2, 3];
const INTERVAL = 300;
const ANIMATION_DURATION = 400;
const ANIMATION_SCALE = 1.4;
function Dot({ active }: { active: boolean }): JSX.Element {
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withTiming(active ? ANIMATION_SCALE : 1, {
duration: ANIMATION_DURATION
});
}, [active]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
const { colors } = useTheme();
const style: StyleProp<ViewStyle> = {
height: SIZE,
width: SIZE,
borderRadius: SIZE / 2,
marginHorizontal: MARGIN,
backgroundColor: active ? colors.dotActiveBg : colors.dotBg
};
return <Animated.View style={[style, animatedStyle]} />;
}
function DotsLoader(): JSX.Element {
const [active, setActive] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
setActive(prevActive => (prevActive > 2 ? 1 : prevActive + 1));
}, INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
return (
<View style={styles.dotsContainer}>
{dots.map(i => (
<Dot key={i} active={i === active} />
))}
</View>
);
}
const styles = StyleSheet.create({
dotsContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginLeft: 6 }
});
export default DotsLoader;

View File

@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { Text, View, useWindowDimensions } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch } from 'react-redux';
import { acceptCall, cancelCall } from '../../../actions/videoConf';
import { ISubscription, SubscriptionType } from '../../../definitions';
import i18n from '../../../i18n';
import { useAppSelector } from '../../../lib/hooks';
import { useEndpointData } from '../../../lib/hooks/useEndpointData';
import { hideNotification } from '../../../lib/methods/helpers/notifications';
import { useTheme } from '../../../theme';
import { CustomIcon } from '../../CustomIcon';
import { CallHeader } from '../../CallHeader';
import { useStyle } from './style';
import useUserData from '../../../lib/hooks/useUserData';
import Ringer, { ERingerSounds } from '../../Ringer';
export interface INotifierComponent {
notification: {
text: string;
payload: {
sender: { username: string };
type: SubscriptionType;
} & Pick<ISubscription, '_id' | 'name' | 'rid' | 'prid'>;
title: string;
avatar: string;
};
isMasterDetail: boolean;
}
const BUTTON_HIT_SLOP = { top: 12, right: 12, bottom: 12, left: 12 };
const IncomingCallHeader = React.memo(
({ uid, callId, avatar, roomName }: { callId: string; avatar: string; uid: string; roomName: string }) => {
const [mic, setMic] = useState(true);
const [cam, setCam] = useState(false);
const dispatch = useDispatch();
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
const styles = useStyle();
const insets = useSafeAreaInsets();
const { height, width } = useWindowDimensions();
const isLandscape = width > height;
const { colors } = useTheme();
return (
<View
style={[
styles.container,
(isMasterDetail || isLandscape) && styles.small,
{
marginTop: insets.top
}
]}
>
<CallHeader
title={i18n.t('Incoming_call_from')}
cam={cam}
setCam={setCam}
mic={mic}
setMic={setMic}
avatar={avatar}
name={roomName}
uid={uid}
direct={true}
/>
<View style={styles.row}>
<Touchable hitSlop={BUTTON_HIT_SLOP} onPress={hideNotification} style={styles.closeButton}>
<CustomIcon name='close' size={20} color={colors.gray300} />
</Touchable>
<Touchable
hitSlop={BUTTON_HIT_SLOP}
onPress={() => {
hideNotification();
dispatch(cancelCall({ callId }));
}}
style={styles.cancelButton}
>
<Text style={styles.buttonText}>{i18n.t('decline')}</Text>
</Touchable>
<Touchable
hitSlop={BUTTON_HIT_SLOP}
onPress={() => {
hideNotification();
dispatch(acceptCall({ callId }));
}}
style={styles.acceptButton}
>
<Text style={styles.buttonText}>{i18n.t('accept')}</Text>
</Touchable>
</View>
<Ringer ringer={ERingerSounds.RINGTONE} />
</View>
);
}
);
const IncomingCallNotification = ({
notification: { rid, callId }
}: {
notification: { rid: string; callId: string };
}): React.ReactElement | null => {
const { result } = useEndpointData('video-conference.info', { callId });
const user = useUserData(rid);
if (result?.success && user.username) {
return <IncomingCallHeader callId={callId} avatar={user.avatar} roomName={user.username} uid={user.uid} />;
}
return null;
};
export default IncomingCallNotification;

View File

@ -0,0 +1,57 @@
import { PixelRatio, StyleSheet } from 'react-native';
import { useTheme } from '../../../theme';
import sharedStyles from '../../../views/Styles';
export const useStyle = () => {
const { colors } = useTheme();
return StyleSheet.create({
container: {
height: 160 * PixelRatio.getFontScale(),
paddingHorizontal: 24,
paddingVertical: 18,
marginHorizontal: 10,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 4,
backgroundColor: colors.focusedBackground,
borderColor: colors.separatorColor,
flex: 1
},
small: {
width: '50%',
alignSelf: 'center'
},
row: {
flexDirection: 'row',
marginTop: 12
},
closeButton: {
backgroundColor: colors.passcodeButtonActive,
marginRight: 8,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
width: 36,
height: 36
},
cancelButton: {
borderRadius: 4,
backgroundColor: colors.cancelCallButton,
marginRight: 8,
flex: 2,
alignItems: 'center',
justifyContent: 'center'
},
buttonText: {
...sharedStyles.textMedium,
color: 'white'
},
acceptButton: {
borderRadius: 4,
backgroundColor: colors.acceptCallButton,
flex: 2,
alignItems: 'center',
justifyContent: 'center'
}
});
};

View File

@ -2,7 +2,6 @@ import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import { connect } from 'react-redux';
import { Notifier } from 'react-native-notifier';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Avatar from '../Avatar';
@ -14,6 +13,7 @@ import { ROW_HEIGHT } from '../RoomItem';
import { goRoom } from '../../lib/methods/helpers/goRoom';
import { useOrientation } from '../../dimensions';
import { IApplicationState, ISubscription, SubscriptionType } from '../../definitions';
import { hideNotification } from '../../lib/methods/helpers/notifications';
export interface INotifierComponent {
notification: {
@ -21,6 +21,7 @@ export interface INotifierComponent {
payload: {
sender: { username: string };
type: SubscriptionType;
message?: { message: string; t?: string };
} & Pick<ISubscription, '_id' | 'name' | 'rid' | 'prid'>;
title: string;
avatar: string;
@ -72,8 +73,6 @@ const styles = StyleSheet.create({
}
});
const hideNotification = () => Notifier.hideNotification();
const NotifierComponent = React.memo(({ notification, isMasterDetail }: INotifierComponent) => {
const { theme } = useTheme();
const insets = useSafeAreaInsets();

View File

@ -1,4 +1,4 @@
import React, { memo, useEffect } from 'react';
import React, { ElementType, memo, useEffect } from 'react';
import { Easing, Notifier, NotifierRoot } from 'react-native-notifier';
import NotifierComponent, { INotifierComponent } from './NotifierComponent';
@ -15,24 +15,32 @@ const InAppNotification = memo(() => {
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background'
}));
const show = (notification: INotifierComponent['notification']) => {
if (appState !== 'foreground') {
return;
const show = (
notification: INotifierComponent['notification'] & {
customComponent?: ElementType;
customTime?: number;
customNotification?: boolean;
hideOnPress?: boolean;
swipeEnabled?: boolean;
}
) => {
if (appState !== 'foreground') return;
const { payload } = notification;
const state = Navigation.navigationRef.current?.getRootState();
const route = getActiveRoute(state);
if (payload.rid) {
if (payload.rid === subscribedRoom || route?.name === 'JitsiMeetView') {
return;
}
if (payload?.rid || notification.customNotification) {
if (payload?.rid === subscribedRoom || route?.name === 'JitsiMeetView' || payload?.message?.t === 'videoconf') return;
Notifier.showNotification({
showEasing: Easing.inOut(Easing.quad),
Component: NotifierComponent,
Component: notification.customComponent || NotifierComponent,
componentProps: {
notification
}
},
duration: notification.customTime || 3000, // default 3s,
hideOnPress: notification.hideOnPress ?? true,
swipeEnabled: notification.swipeEnabled ?? true
});
}
};

Binary file not shown.

View File

@ -0,0 +1,43 @@
import { Audio } from 'expo-av';
import React, { useEffect, useRef } from 'react';
import { View } from 'react-native';
export enum ERingerSounds {
DIALTONE = 'dialtone',
RINGTONE = 'ringtone'
}
const Ringer = React.memo(({ ringer }: { ringer: ERingerSounds }) => {
console.log('Ringer', ringer);
const sound = useRef<Audio.Sound | null>(null);
useEffect(() => {
(async () => {
let expo = null;
switch (ringer) {
case ERingerSounds.DIALTONE:
expo = await Audio.Sound.createAsync(require(`./dialtone.mp3`));
break;
case ERingerSounds.RINGTONE:
expo = await Audio.Sound.createAsync(require(`./ringtone.mp3`));
break;
default:
expo = await Audio.Sound.createAsync(require(`./dialtone.mp3`));
break;
}
sound.current = expo.sound;
await sound.current.playAsync();
await sound.current.setIsLoopingAsync(true);
})();
}, []);
useEffect(() => () => stopSound(), []);
const stopSound = () => {
sound?.current?.unloadAsync();
};
return <View />;
});
export default Ringer;

Binary file not shown.

View File

@ -50,6 +50,11 @@ const formatMsg = ({ lastMessage, type, showLastMessage, username, useRealName }
prefix = `${useRealName ? name : lastMessage.u.username}: `;
}
if (lastMessage.t === 'videoconf') {
prefix = '';
lastMessage.msg = I18n.t('Call_started');
}
return `${prefix}${lastMessage.msg}`;
};

View File

@ -1,103 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import i18n from '../../../../i18n';
import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription';
import { useAppSelector } from '../../../../lib/hooks';
import { getRoomAvatar, getUidDirectMessage } from '../../../../lib/methods/helpers';
import { Services } from '../../../../lib/services';
import { useTheme } from '../../../../theme';
import { useActionSheet } from '../../../ActionSheet';
import AvatarContainer from '../../../Avatar';
import Button from '../../../Button';
import { CustomIcon } from '../../../CustomIcon';
import StatusContainer from '../../../Status';
import { BUTTON_HIT_SLOP } from '../../../message/utils';
import useStyle from './styles';
const useUserData = (rid: string) => {
const [user, setUser] = useState({ username: '', avatar: '', uid: '', type: '' });
useEffect(() => {
(async () => {
const room = await getSubscriptionByRoomId(rid);
if (room) {
const uid = (await getUidDirectMessage(room)) as string;
const avt = getRoomAvatar(room);
setUser({ uid, username: room?.name || '', avatar: avt, type: room?.t || '' });
} else {
try {
const result = await Services.getUserInfo(rid);
if (result.success) {
setUser({
username: result.user.name || result.user.username,
avatar: result.user.username,
uid: result.user._id,
type: 'd'
});
}
} catch (error) {
//
}
}
})();
}, []);
return user;
};
export default function StartACallActionSheet({ rid, initCall }: { rid: string; initCall: Function }): React.ReactElement {
const style = useStyle();
const { colors } = useTheme();
const [mic, setMic] = useState(true);
const [cam, setCam] = useState(false);
const username = useAppSelector(state => state.login.user.username);
const { hideActionSheet } = useActionSheet();
const user = useUserData(rid);
const handleColor = (enabled: boolean) => (enabled ? colors.conferenceCallEnabledIcon : colors.conferenceCallDisabledIcon);
return (
<View style={style.actionSheetContainer}>
<View style={style.actionSheetHeader}>
<Text style={style.actionSheetHeaderTitle}>{i18n.t('Start_a_call')}</Text>
<View style={style.actionSheetHeaderButtons}>
<Touchable
onPress={() => setCam(!cam)}
style={[style.iconCallContainer, cam && style.enabledBackground, { marginRight: 6 }]}
hitSlop={BUTTON_HIT_SLOP}
>
<CustomIcon name={cam ? 'camera' : 'camera-disabled'} size={20} color={handleColor(cam)} />
</Touchable>
<Touchable
onPress={() => setMic(!mic)}
style={[style.iconCallContainer, mic && style.enabledBackground]}
hitSlop={BUTTON_HIT_SLOP}
>
<CustomIcon name={mic ? 'microphone' : 'microphone-disabled'} size={20} color={handleColor(mic)} />
</Touchable>
</View>
</View>
<View style={style.actionSheetUsernameContainer}>
<AvatarContainer text={user.avatar} size={36} rid={rid} type={user.type} />
<StatusContainer size={16} id={user.uid} style={{ marginLeft: 8, marginRight: 6 }} />
<Text style={style.actionSheetUsername} numberOfLines={1}>
{user.username}
</Text>
</View>
<View style={style.actionSheetPhotoContainer}>
<AvatarContainer size={62} text={username} />
</View>
<Button
onPress={() => {
hideActionSheet();
setTimeout(() => {
initCall({ cam, mic });
}, 100);
}}
title={i18n.t('Call')}
/>
</View>
);
}

View File

@ -1,20 +1,15 @@
import React from 'react';
import { Text } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import i18n from '../../../../i18n';
import { videoConfJoin } from '../../../../lib/methods/videoConf';
import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
const VideoConferenceDirect = React.memo(({ blockId }: { blockId: string }) => {
const VideoConferenceDirect = React.memo(() => {
const style = useStyle();
return (
<VideoConferenceBaseContainer variant='incoming'>
<Touchable style={style.callToActionButton} onPress={() => videoConfJoin(blockId)}>
<Text style={style.callToActionButtonText}>{i18n.t('Join')}</Text>
</Touchable>
<Text style={style.callBack}>{i18n.t('Waiting_for_answer')}</Text>
</VideoConferenceBaseContainer>
);

View File

@ -5,7 +5,7 @@ import sharedStyles from '../../../../views/Styles';
export default function useStyle() {
const { colors } = useTheme();
return StyleSheet.create({
const style = StyleSheet.create({
container: { height: 108, flex: 1, borderWidth: 1, borderRadius: 4, marginTop: 8, borderColor: colors.conferenceCallBorder },
callInfoContainer: { flex: 1, alignItems: 'center', paddingLeft: 16, flexDirection: 'row' },
infoContainerText: {
@ -88,40 +88,10 @@ export default function useStyle() {
...sharedStyles.textRegular,
color: colors.passcodeSecondary
},
actionSheetContainer: {
paddingHorizontal: 24,
flex: 1
},
actionSheetHeaderTitle: {
fontSize: 14,
...sharedStyles.textBold,
color: colors.passcodePrimary
},
actionSheetUsername: {
fontSize: 16,
...sharedStyles.textBold,
color: colors.passcodePrimary,
flexShrink: 1
},
enabledBackground: {
backgroundColor: colors.conferenceCallEnabledIconBackground
},
iconCallContainer: {
padding: 6,
borderRadius: 4
},
actionSheetHeader: { flexDirection: 'row', alignItems: 'center' },
actionSheetHeaderButtons: { flex: 1, alignItems: 'center', flexDirection: 'row', justifyContent: 'flex-end' },
actionSheetUsernameContainer: { flexDirection: 'row', paddingTop: 8, alignItems: 'center' },
actionSheetPhotoContainer: {
height: 220,
width: 148,
backgroundColor: colors.conferenceCallPhotoBackground,
borderRadius: 8,
margin: 24,
alignSelf: 'center',
justifyContent: 'center',
alignItems: 'center'
}
});
return style;
}

View File

@ -14,7 +14,7 @@ export default function VideoConferenceBlock({ callId, blockId }: { callId: stri
if ('endedAt' in result) return <VideoConferenceEnded createdBy={createdBy} rid={rid} type={type} users={users} />;
if (type === 'direct' && status === 0) return <VideoConferenceDirect blockId={blockId} />;
if (type === 'direct' && status === 0) return <VideoConferenceDirect />;
return <VideoConferenceOutgoing blockId={blockId} users={users} />;
}

View File

@ -26,7 +26,8 @@ const MarkdownPreview = ({ msg, numberOfLines = 1, style = [], testID }: IMarkdo
accessibilityLabel={m}
style={[styles.text, { color: themes[theme].bodyText }, ...style]}
numberOfLines={numberOfLines}
testID={testID || `markdown-preview-${m}`}>
testID={testID || `markdown-preview-${m}`}
>
{m}
</Text>
);

View File

@ -107,3 +107,5 @@ export type VideoConfListProps = {
};
export type VideoConfInfoProps = { callId: string };
export type VideoConfCall = VideoConference & { capabilities: VideoConferenceCapabilities };

View File

@ -16,6 +16,7 @@ import { TActionSortPreferences } from '../../actions/sortPreferences';
import { TActionUserTyping } from '../../actions/usersTyping';
import { TActionPermissions } from '../../actions/permissions';
import { TActionEnterpriseModules } from '../../actions/enterpriseModules';
import { TActionVideoConf } from '../../actions/videoConf';
// REDUCERS
import { IActiveUsers } from '../../reducers/activeUsers';
import { IApp } from '../../reducers/app';
@ -34,6 +35,7 @@ import { IShare } from '../../reducers/share';
import { IInquiry } from '../../ee/omnichannel/reducers/inquiry';
import { IPermissionsState } from '../../reducers/permissions';
import { IEnterpriseModules } from '../../reducers/enterpriseModules';
import { IVideoConf } from '../../reducers/videoConf';
export interface IApplicationState {
settings: TSettingsState;
@ -57,6 +59,7 @@ export interface IApplicationState {
encryption: IEncryption;
permissions: IPermissionsState;
roles: IRoles;
videoConf: IVideoConf;
}
export type TApplicationActions = TActionActiveUsers &
@ -75,4 +78,5 @@ export type TApplicationActions = TActionActiveUsers &
TActionApp &
TActionInquiry &
TActionPermissions &
TActionEnterpriseModules;
TActionEnterpriseModules &
TActionVideoConf;

View File

@ -1,4 +1,5 @@
import {
VideoConfCall,
VideoConfCancelProps,
VideoConference,
VideoConferenceCapabilities,
@ -24,7 +25,7 @@ export type VideoConferenceEndpoints = {
};
'video-conference.info': {
GET: (params: VideoConfInfoProps) => VideoConference & { capabilities: VideoConferenceCapabilities };
GET: (params: VideoConfInfoProps) => VideoConfCall;
};
'video-conference.list': {

View File

@ -691,11 +691,12 @@
"Waiting_for_answer": "Waiting for answer",
"Call_ended": "Call ended",
"Call_was_not_answered": "Call was not answered",
"Call_back": "Call back",
"Call_again": "Call again",
"Call_ongoing": "Call ongoing",
"Call_rejected": "Call rejected",
"Call_back": "Call Back",
"Call_again": "Call Again",
"Call_ongoing": "Call Ongoing",
"Joined": "Joined",
"Calling": "Calling...",
"Calling": "Calling",
"Start_a_call": "Start a call",
"Call": "Call",
"Reply_in_direct_message": "Reply in direct message",
@ -724,5 +725,9 @@
"Presence_Cap_Warning_Title": "User status temporarily disabled",
"Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Learn_more": "Learn more",
"and_N_more": "and {{count}} more"
"and_N_more": "and {{count}} more",
"decline": "Decline",
"accept": "Accept",
"Incoming_call_from": "Incoming call from",
"Call_started": "Call started"
}

View File

@ -695,7 +695,7 @@
"Call_again": "Soita uudelleen",
"Call_ongoing": "Puhelu käynnissä",
"Joined": "Liitytty",
"Calling": "Soitetaan...",
"Calling": "Soitetaan",
"Start_a_call": "Aloita puhelu",
"Call": "Soita",
"Reply_in_direct_message": "Vastaa suoralla viestillä",

View File

@ -695,7 +695,7 @@
"Call_again": "Ligue novamente",
"Call_ongoing": "Chamada em andamento",
"Joined": "Ingressou",
"Calling": "Chamando...",
"Calling": "Chamando",
"Start_a_call": "Inicie uma chamada",
"Call": "Ligar",
"Reply_in_direct_message": "Responder por mensagem direta",
@ -711,6 +711,10 @@
"Discard_changes_description": "Todas as alterações serão perdidas, se você sair sem salvar.",
"Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente",
"Presence_Cap_Warning_Description": "O limite de conexões ativas para a workspace foi atingido, por isso o serviço responsável pela presença dos usuários está temporariamente desabilitado. Ele pode ser reabilitado manualmente nas configurações da workspace.",
"decline": "Recusar",
"accept": "Aceitar",
"Incoming_call_from": "Chamada recebida de",
"Call_started": "Chamada Iniciada",
"Learn_more": "Saiba mais",
"and_N_more": "e mais {{count}}"
}

View File

@ -693,7 +693,7 @@
"Call_again": "Ring igen",
"Call_ongoing": "Samtal pågår",
"Joined": "Anslöt",
"Calling": "Ringer upp...",
"Calling": "Ringer upp",
"Start_a_call": "Starta ett samtal",
"Call": "Ring",
"Reply_in_direct_message": "Svara med direktmeddelande",

View File

@ -20,6 +20,11 @@ const mentions = {
mentionOtherColor: '#F3BE08'
};
const callButtons = {
cancelCallButton: '#F5455C',
acceptCallButton: '#158D65'
};
export const colors = {
light: {
backgroundColor: '#ffffff',
@ -89,7 +94,13 @@ export const colors = {
conferenceCallEnabledIconBackground: '#156FF5',
conferenceCallPhotoBackground: '#E4E7EA',
textInputSecondaryBackground: '#E4E7EA',
...mentions
dotBg: '#a9cbff',
dotActiveBg: '#1d74f5',
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#1F2329',
...mentions,
...callButtons
},
dark: {
backgroundColor: '#030b1b',
@ -158,8 +169,14 @@ export const colors = {
conferenceCallEnabledIcon: '#FFFFFF',
conferenceCallEnabledIconBackground: '#156FF5',
conferenceCallPhotoBackground: '#E4E7EA',
textInputSecondaryBackground: '#030b1b', // backgroundColor
...mentions
textInputSecondaryBackground: '#030b1b',
dotBg: '#a9cbff',
dotActiveBg: '#1d74f5',
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#FFFFFF',
...mentions,
...callButtons
},
black: {
backgroundColor: '#000000',
@ -228,8 +245,14 @@ export const colors = {
conferenceCallEnabledIcon: '#FFFFFF',
conferenceCallEnabledIconBackground: '#156FF5',
conferenceCallPhotoBackground: '#E4E7EA',
textInputSecondaryBackground: '#000000', // backgroundColor
...mentions
textInputSecondaryBackground: '#000000',
dotBg: '#a9cbff',
dotActiveBg: '#1d74f5',
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#FFFFFF',
...mentions,
...callButtons
}
};

View File

@ -1,10 +1,11 @@
import database from '..';
import { TSubscriptionModel } from '../../../definitions';
import { TAppDatabase } from '../interfaces';
import { SUBSCRIPTIONS_TABLE } from '../model/Subscription';
const getCollection = (db: TAppDatabase) => db.get(SUBSCRIPTIONS_TABLE);
export const getSubscriptionByRoomId = async (rid: string) => {
export const getSubscriptionByRoomId = async (rid: string): Promise<TSubscriptionModel | null> => {
const db = database.active;
const subCollection = getCollection(db);
try {

View File

@ -1,5 +1,10 @@
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import { select } from 'redux-saga/effects';
import { IApplicationState } from '../../definitions';
export const useAppSelector: TypedUseSelectorHook<IApplicationState> = useSelector;
export function* appSelector<TSelected>(selector: (state: IApplicationState) => TSelected): Generator<any, TSelected, TSelected> {
return yield select(selector);
}

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import { getRoomAvatar, getUidDirectMessage } from '../methods/helpers';
import { SubscriptionType } from '../../definitions';
import { Services } from '../services';
import { useAppSelector } from './useAppSelector';
const useUserData = (rid: string) => {
const [user, setUser] = useState({ username: '', avatar: '', uid: '', type: '', direct: false });
const { useRealName } = useAppSelector(state => ({
useRealName: state.settings.UI_Use_Real_Name as boolean
}));
useEffect(() => {
(async () => {
const room = await getSubscriptionByRoomId(rid);
if (room) {
const uid = (await getUidDirectMessage(room)) as string;
const avt = getRoomAvatar(room);
const username = useRealName && room.fname ? room.fname : room.name;
setUser({
uid,
username,
avatar: avt,
type: room?.t || '',
direct: room?.t === SubscriptionType.DIRECT
});
} else {
try {
const result = await Services.getUserInfo(rid);
if (result.success) {
const { user } = result;
const username = useRealName && user.name ? user.name : user.username;
setUser({
username,
avatar: user.username,
uid: user._id,
type: SubscriptionType.DIRECT,
direct: true
});
}
} catch (error) {
//
}
}
})();
}, []);
return user;
};
export default useUserData;

View File

@ -0,0 +1,106 @@
import { Camera, CameraType } from 'expo-camera';
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch } from 'react-redux';
import { useAppSelector } from '..';
import { cancelCall, initVideoCall } from '../../../actions/videoConf';
import AvatarContainer from '../../../containers/Avatar';
import Button from '../../../containers/Button';
import { CallHeader } from '../../../containers/CallHeader';
import Ringer, { ERingerSounds } from '../../../containers/Ringer';
import i18n from '../../../i18n';
import { getUserSelector } from '../../../selectors/login';
import { useTheme } from '../../../theme';
import { isIOS } from '../../methods/helpers';
import useUserData from '../useUserData';
export default function StartACallActionSheet({ rid }: { rid: string }): React.ReactElement {
const { colors } = useTheme();
const [mic, setMic] = useState(true);
const [cam, setCam] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const username = useAppSelector(state => getUserSelector(state).username);
const calling = useAppSelector(state => state.videoConf.calling);
const dispatch = useDispatch();
const user = useUserData(rid);
// fix safe area bottom padding on iOS
const insets = useSafeAreaInsets();
const paddingBottom = isIOS && insets.bottom ? 8 : 0;
React.useEffect(
() => () => {
if (calling) {
dispatch(cancelCall({}));
}
},
[calling, dispatch]
);
return (
<View
style={[style.actionSheetContainer, { paddingBottom }]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width / 2)}
>
{calling ? <Ringer ringer={ERingerSounds.DIALTONE} /> : null}
<CallHeader
title={calling && user.direct ? i18n.t('Calling') : i18n.t('Start_a_call')}
cam={cam}
mic={mic}
setCam={setCam}
setMic={setMic}
avatar={user.avatar}
name={user.username}
uid={user.uid}
direct={user.direct}
/>
<View
style={[
style.actionSheetPhotoContainer,
{ backgroundColor: cam ? undefined : colors.conferenceCallPhotoBackground, width: containerWidth }
]}
>
{cam ? (
<Camera style={[style.cameraContainer, { width: containerWidth }]} type={CameraType.front} />
) : (
<AvatarContainer size={62} text={username} rid={rid} type={user.type} />
)}
</View>
<Button
backgroundColor={calling ? colors.conferenceCallCallBackButton : colors.actionTintColor}
color={calling ? colors.gray300 : colors.conferenceCallEnabledIcon}
onPress={() => {
if (calling) {
dispatch(cancelCall({}));
} else {
dispatch(initVideoCall({ cam, mic, direct: user.direct, rid, uid: user.uid }));
}
}}
title={calling ? i18n.t('Cancel') : i18n.t('Call')}
/>
</View>
);
}
const style = StyleSheet.create({
actionSheetContainer: {
paddingHorizontal: 24,
flex: 1
},
actionSheetPhotoContainer: {
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
flex: 1,
marginVertical: 8,
borderRadius: 8,
overflow: 'hidden'
},
cameraContainer: {
flex: 1
}
});

View File

@ -1,17 +1,17 @@
import { Camera } from 'expo-camera';
import React, { useEffect, useState } from 'react';
import { useActionSheet } from '../../containers/ActionSheet';
import StartACallActionSheet from '../../containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet';
import { ISubscription, SubscriptionType } from '../../definitions';
import i18n from '../../i18n';
import { getUserSelector } from '../../selectors/login';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import { callJitsi } from '../methods';
import { compareServerVersion, showErrorAlert } from '../methods/helpers';
import { videoConfStartAndJoin } from '../methods/videoConf';
import { Services } from '../services';
import { useAppSelector } from './useAppSelector';
import { useSnaps } from './useSnaps';
import { useActionSheet } from '../../../containers/ActionSheet';
import { SubscriptionType } from '../../../definitions';
import i18n from '../../../i18n';
import { getUserSelector } from '../../../selectors/login';
import { getSubscriptionByRoomId } from '../../database/services/Subscription';
import { compareServerVersion, showErrorAlert } from '../../methods/helpers';
import { handleAndroidBltPermission } from '../../methods/videoConf';
import { Services } from '../../services';
import { useAppSelector } from '../useAppSelector';
import { useSnaps } from '../useSnaps';
import StartACallActionSheet from './StartACallActionSheet';
const availabilityErrors = {
NOT_CONFIGURED: 'video-conf-provider-not-configured',
@ -33,6 +33,8 @@ export const useVideoConf = (rid: string): { showInitCallActionSheet: () => Prom
const jitsiEnableChannels = useAppSelector(state => state.settings.Jitsi_Enable_Channels);
const user = useAppSelector(state => getUserSelector(state));
const [permission, requestPermission] = Camera.useCameraPermissions();
const isServer5OrNewer = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0');
const { showActionSheet } = useActionSheet();
@ -76,19 +78,17 @@ export const useVideoConf = (rid: string): { showInitCallActionSheet: () => Prom
return true;
};
const initCall = async ({ cam, mic }: { cam: boolean; mic: boolean }) => {
if (isServer5OrNewer) return videoConfStartAndJoin({ rid, cam, mic });
const room = (await getSubscriptionByRoomId(rid)) as ISubscription;
callJitsi({ room, cam });
};
const showInitCallActionSheet = async () => {
const canInit = await canInitAnCall();
if (canInit) {
showActionSheet({
children: <StartACallActionSheet rid={rid} initCall={initCall} />,
children: <StartACallActionSheet rid={rid} />,
snaps
});
if (!permission?.granted) {
requestPermission();
handleAndroidBltPermission();
}
}
};

View File

@ -0,0 +1,3 @@
import { Notifier } from 'react-native-notifier';
export const hideNotification = (): void => Notifier.hideNotification();

View File

@ -34,6 +34,7 @@ import { E2E_MESSAGE_TYPE } from '../../constants';
import { getRoom } from '../getRoom';
import { merge } from '../helpers/mergeSubscriptionsRooms';
import { getRoomAvatar, getRoomTitle, getSenderName, random } from '../helpers';
import { handleVideoConfIncomingWebsocketMessages } from '../../../actions/videoConf';
const removeListener = (listener: { stop: () => void }) => listener.stop();
@ -409,6 +410,11 @@ export default function subscribeRooms() {
log(e);
}
}
if (/video-conference/.test(ev)) {
const [action, params] = ddpMessage.fields.args;
store.dispatch(handleVideoConfIncomingWebsocketMessages({ action, params }));
}
});
const stop = () => {

View File

@ -19,18 +19,17 @@ const handleBltPermission = async (): Promise<Permission[]> => {
return [PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION];
};
export const handleAndroidBltPermission = async (): Promise<void> => {
if (isAndroid) {
const bltPermission = await handleBltPermission();
await PermissionsAndroid.requestMultiple(bltPermission);
}
};
export const videoConfJoin = async (callId: string, cam?: boolean, mic?: boolean): Promise<void> => {
try {
const result = await Services.videoConferenceJoin(callId, cam, mic);
if (result.success) {
if (isAndroid) {
const bltPermission = await handleBltPermission();
await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.CAMERA,
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
...bltPermission
]);
}
const { url, providerName } = result;
if (providerName === 'jitsi') {
navigation.navigate('JitsiMeetView', { url, onlyAudio: !cam, videoConf: true });
@ -43,15 +42,3 @@ export const videoConfJoin = async (callId: string, cam?: boolean, mic?: boolean
log(e);
}
};
export const videoConfStartAndJoin = async ({ rid, cam, mic }: { rid: string; cam?: boolean; mic?: boolean }): Promise<void> => {
try {
const videoConfResponse = await Services.videoConferenceStart(rid);
if (videoConfResponse.success) {
videoConfJoin(videoConfResponse.data.callId, cam, mic);
}
} catch (e) {
showErrorAlert(i18n.t('error-init-video-conf'));
log(e);
}
};

View File

@ -947,7 +947,9 @@ export const videoConferenceJoin = (callId: string, cam?: boolean, mic?: boolean
export const videoConferenceGetCapabilities = () => sdk.get('video-conference.capabilities');
export const videoConferenceStart = (roomId: string) => sdk.post('video-conference.start', { roomId });
export const videoConferenceStart = (roomId: string) => sdk.post('video-conference.start', { roomId, allowRinging: true });
export const videoConferenceCancel = (callId: string) => sdk.post('video-conference.cancel', { callId });
export const saveUserProfileMethod = (
params: IProfileParams,
@ -961,3 +963,6 @@ export const saveUserProfileMethod = (
export const deleteOwnAccount = (password: string, confirmRelinquish = false): any =>
// RC 0.67.0
sdk.post('users.deleteOwnAccount', { password, confirmRelinquish });
export const notifyUser = (type: string, params: Record<string, any>): Promise<boolean> =>
sdk.methodCall('stream-notify-user', type, params);

View File

@ -21,6 +21,7 @@ import enterpriseModules from './enterpriseModules';
import encryption from './encryption';
import permissions from './permissions';
import roles from './roles';
import videoConf from './videoConf';
export default combineReducers({
settings,
@ -43,5 +44,6 @@ export default combineReducers({
enterpriseModules,
encryption,
permissions,
roles
roles,
videoConf
});

View File

@ -0,0 +1,63 @@
import { clearVideoConfCalls, removeVideoConfCall, setVideoConfCall, setCalling } from '../actions/videoConf';
import { mockedStore } from './mockedStore';
import { initialState, ICallInfo } from './videoConf';
describe('test videoConf reducer', () => {
it('should return initial state', () => {
const state = mockedStore.getState().videoConf;
expect(state).toEqual(initialState);
});
const call1: ICallInfo = {
callId: '123',
rid: '123',
action: 'accepted',
uid: '123'
};
const call2: ICallInfo = {
callId: '321',
rid: '321',
action: 'accepted',
uid: '321'
};
it('should return call1 after call addSettings action with call1 as parameter', () => {
mockedStore.dispatch(setVideoConfCall(call1));
const state = mockedStore.getState().videoConf;
const call = state.calls.find(c => c.callId === call1.callId);
expect(call).toEqual(call1);
});
it('should return call2 after call addSettings action with call2 as parameter', () => {
mockedStore.dispatch(setVideoConfCall(call2));
const state = mockedStore.getState().videoConf;
const call = state.calls.find(c => c.callId === call2.callId);
expect(call).toEqual(call2);
});
it('should remove call1 after call removeVideoConfCall action with call1 as parameter', () => {
mockedStore.dispatch(removeVideoConfCall(call1));
const state = mockedStore.getState().videoConf;
const call = state.calls.find(c => c.callId === call1.callId);
expect(call).toEqual(undefined);
});
it('should set calling true after call setCalling action with true as parameter', () => {
mockedStore.dispatch(setCalling(true));
const state = mockedStore.getState().videoConf;
expect(state.calling).toEqual(true);
});
it('should set calling false after call setCalling action with false as parameter', () => {
mockedStore.dispatch(setCalling(false));
const state = mockedStore.getState().videoConf;
expect(state.calling).toEqual(false);
});
it('should return initial state after clearSettings', () => {
mockedStore.dispatch(clearVideoConfCalls());
const state = mockedStore.getState().videoConf;
expect(state).toEqual(initialState);
});
});

39
app/reducers/videoConf.ts Normal file
View File

@ -0,0 +1,39 @@
import { VIDEO_CONF } from '../actions/actionsTypes';
import { TActionVideoConf } from '../actions/videoConf';
export type TSupportedCallStatus = 'call' | 'canceled' | 'accepted' | 'rejected' | 'confirmed' | 'join' | 'end' | 'calling';
export interface ICallInfo {
callId: string;
rid: string;
uid: string;
action?: TSupportedCallStatus;
}
export interface IVideoConf {
calls: ICallInfo[];
calling: boolean;
}
export const initialState: IVideoConf = { calls: [], calling: false };
export default (state = initialState, action: TActionVideoConf): IVideoConf => {
switch (action.type) {
case VIDEO_CONF.SET:
return {
...state,
calls: [...state.calls, action.payload]
};
case VIDEO_CONF.REMOVE:
return {
...state,
calls: state.calls.filter(call => call.callId !== action.payload.callId)
};
case VIDEO_CONF.CLEAR:
return initialState;
case VIDEO_CONF.SET_CALLING:
return { ...state, calling: action.payload };
default:
return state;
}
};

View File

@ -13,6 +13,7 @@ import deepLinking from './deepLinking';
import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion';
import encryption from './encryption';
import videoConf from './videoConf';
const root = function* root() {
yield all([
@ -28,7 +29,8 @@ const root = function* root() {
inviteLinks(),
createDiscussion(),
inquiry(),
encryption()
encryption(),
videoConf()
]);
};

251
app/sagas/videoConf.ts Normal file
View File

@ -0,0 +1,251 @@
import { Action } from 'redux';
import { delay, put, takeEvery } from 'redux-saga/effects';
import { call } from 'typed-redux-saga';
import { VIDEO_CONF } from '../actions/actionsTypes';
import { removeVideoConfCall, setCalling, setVideoConfCall, TCallProps } from '../actions/videoConf';
import { hideActionSheetRef } from '../containers/ActionSheet';
import { INAPP_NOTIFICATION_EMITTER } from '../containers/InAppNotification';
import IncomingCallNotification from '../containers/InAppNotification/IncomingCallNotification';
import i18n from '../i18n';
import { getSubscriptionByRoomId } from '../lib/database/services/Subscription';
import { appSelector } from '../lib/hooks';
import { callJitsi } from '../lib/methods';
import { compareServerVersion, showErrorAlert } from '../lib/methods/helpers';
import EventEmitter from '../lib/methods/helpers/events';
import log from '../lib/methods/helpers/log';
import { hideNotification } from '../lib/methods/helpers/notifications';
import { showToast } from '../lib/methods/helpers/showToast';
import { videoConfJoin } from '../lib/methods/videoConf';
import { Services } from '../lib/services';
import { notifyUser } from '../lib/services/restApi';
import { ICallInfo } from '../reducers/videoConf';
interface IGenericAction extends Action {
type: string;
}
type THandleGeneric = IGenericAction & {
data: any;
};
type TInitCallGeneric = IGenericAction & {
payload: TCallProps;
};
type TCancelCallGeneric = IGenericAction & {
payload?: { callId?: string };
};
type TAcceptCallGeneric = IGenericAction & {
payload: { callId: string };
};
// The interval between attempts to call the remote user
const CALL_INTERVAL = 3000;
// How many attempts to call we're gonna make
const CALL_ATTEMPT_LIMIT = 10;
function* onDirectCall(payload: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === payload.callId);
const hasAnotherCall = calls.find(c => c.action === 'call');
if (hasAnotherCall && hasAnotherCall.callId !== payload.callId) return;
if (!currentCall) {
yield put(setVideoConfCall(payload));
EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, {
// @ts-ignore - Component props do not match Event emitter props
customComponent: IncomingCallNotification,
customTime: 30000,
customNotification: true,
hideOnPress: false,
swipeEnabled: false,
...payload
});
}
}
function* onDirectCallCanceled(payload: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === payload.callId);
if (currentCall) {
yield put(removeVideoConfCall(currentCall));
hideNotification();
}
}
function* onDirectCallAccepted({ callId, rid, uid, action }: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const userId = yield* appSelector(state => state.login.user.id);
const currentCall = calls.find(c => c.callId === callId);
if (currentCall && currentCall.action === 'calling') {
yield call(notifyUser, `${uid}/video-conference`, { action: 'confirmed', params: { uid: userId, rid, callId } });
yield put(setVideoConfCall({ callId, rid, uid, action }));
}
}
function* onDirectCallRejected() {
yield call(cancelCall, {});
showToast(i18n.t('Call_rejected'));
yield call(hideActionSheetRef);
}
function* onDirectCallConfirmed(payload: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === payload.callId);
if (currentCall) {
yield put(removeVideoConfCall(currentCall));
yield call(hideActionSheetRef);
videoConfJoin(payload.callId, false, true);
}
}
function* onDirectCallJoined(payload: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === payload.callId);
if (currentCall && (currentCall.action === 'accepted' || currentCall.action === 'calling')) {
yield put(setCalling(false));
yield put(removeVideoConfCall(currentCall));
yield call(hideActionSheetRef);
videoConfJoin(payload.callId, false, true);
}
}
function* onDirectCallEnded(payload: ICallInfo) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === payload.callId);
if (currentCall) {
yield put(removeVideoConfCall(currentCall));
hideNotification();
}
}
function* handleVideoConfIncomingWebsocketMessages({ data }: { data: any }) {
const { action, params } = data.action;
if (!action || typeof action !== 'string') {
return;
}
if (!params || typeof params !== 'object' || !params.callId || !params.uid || !params.rid) {
return;
}
const prop = { ...params, action };
switch (action) {
case 'call':
yield call(onDirectCall, prop);
break;
case 'canceled':
yield call(onDirectCallCanceled, prop);
break;
case 'accepted':
yield call(onDirectCallAccepted, prop);
break;
case 'rejected':
yield call(onDirectCallRejected, prop);
break;
case 'confirmed':
yield call(onDirectCallConfirmed, prop);
break;
case 'join':
yield call(onDirectCallJoined, prop);
break;
case 'end':
yield call(onDirectCallEnded, prop);
break;
}
}
function* initCall({ payload: { mic, cam, direct, rid } }: { payload: TCallProps }) {
yield put(setCalling(true));
const serverVersion = yield* appSelector(state => state.server.version);
const isServer5OrNewer = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0');
if (isServer5OrNewer) {
try {
const videoConfResponse = yield* call(Services.videoConferenceStart, rid);
if (videoConfResponse.success) {
if (direct && videoConfResponse.data.type === 'direct') {
yield call(callUser, { rid, uid: videoConfResponse.data.calleeId, callId: videoConfResponse.data.callId });
} else {
videoConfJoin(videoConfResponse.data.callId, cam, mic);
yield call(hideActionSheetRef);
yield put(setCalling(false));
}
}
} catch (e) {
yield put(setCalling(false));
showErrorAlert(i18n.t('error-init-video-conf'));
log(e);
}
} else {
const sub = yield* call(getSubscriptionByRoomId, rid);
if (sub) {
callJitsi({ room: sub, cam });
yield put(setCalling(false));
}
}
}
function* giveUp({ rid, uid, callId, rejected }: { rid: string; uid: string; callId: string; rejected?: boolean }) {
yield put(removeVideoConfCall({ rid, uid, callId }));
yield call(notifyUser, `${uid}/video-conference`, { action: rejected ? 'rejected' : 'canceled', params: { uid, rid, callId } });
if (!rejected) {
yield put(setCalling(false));
yield call(Services.videoConferenceCancel, callId);
}
}
function* cancelCall({ payload }: { payload?: { callId?: string } }) {
const calls = yield* appSelector(state => state.videoConf.calls);
if (payload?.callId) {
const currentCall = calls.find(c => c.callId === payload.callId);
if (currentCall) {
yield call(giveUp, { ...currentCall, rejected: true });
}
} else {
const currentCall = calls.find(c => c.action === 'calling');
if (currentCall && currentCall.callId) {
yield call(giveUp, currentCall);
}
}
}
function* callUser({ rid, uid, callId }: { rid: string; uid: string; callId: string }) {
const userId = yield* appSelector(state => state.login.user.id);
yield put(setVideoConfCall({ rid, uid, callId, action: 'calling' }));
for (let attempt = 1; attempt <= CALL_ATTEMPT_LIMIT; attempt++) {
if (attempt < CALL_ATTEMPT_LIMIT) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === callId);
if (!currentCall || currentCall.action !== 'calling') {
break;
}
yield call(notifyUser, `${uid}/video-conference`, { action: 'call', params: { uid: userId, rid, callId } });
yield delay(CALL_INTERVAL);
} else {
hideActionSheetRef();
yield call(giveUp, { uid, rid, callId });
break;
}
}
}
function* acceptCall({ payload: { callId } }: { payload: { callId: string } }) {
const calls = yield* appSelector(state => state.videoConf.calls);
const currentCall = calls.find(c => c.callId === callId);
if (currentCall && currentCall.action === 'call') {
const userId = yield* appSelector(state => state.login.user.id);
yield call(notifyUser, `${currentCall.uid}/video-conference`, {
action: 'accepted',
params: { uid: userId, rid: currentCall.rid, callId: currentCall.callId }
});
yield put(setVideoConfCall({ ...currentCall, action: 'accepted' }));
hideNotification();
}
}
export default function* root(): Generator {
yield takeEvery<THandleGeneric>(VIDEO_CONF.HANDLE_INCOMING_WEBSOCKET_MESSAGES, handleVideoConfIncomingWebsocketMessages);
yield takeEvery<TInitCallGeneric>(VIDEO_CONF.INIT_CALL, initCall);
yield takeEvery<TCancelCallGeneric>(VIDEO_CONF.CANCEL_CALL, cancelCall);
yield takeEvery<TAcceptCallGeneric>(VIDEO_CONF.ACCEPT_CALL, acceptCall);
}

View File

@ -8,6 +8,8 @@ PODS:
- EXAV (13.2.1):
- ExpoModulesCore
- ReactCommon/turbomodule/core
- EXCamera (13.2.1):
- ExpoModulesCore
- EXFileSystem (15.2.2):
- ExpoModulesCore
- Expo (48.0.9):
@ -602,6 +604,7 @@ DEPENDENCIES:
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXAV (from `../node_modules/expo-av/ios`)
- EXCamera (from `../node_modules/expo-camera/ios`)
- EXFileSystem (from `../node_modules/expo-file-system/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
@ -721,6 +724,8 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXAV:
:path: "../node_modules/expo-av/ios"
EXCamera:
:path: "../node_modules/expo-camera/ios"
EXFileSystem:
:path: "../node_modules/expo-file-system/ios"
Expo:
@ -892,6 +897,7 @@ SPEC CHECKSUMS:
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
EXAV: f1f69397ecdcf44cfacd4ff5d338cd1b96891e87
EXCamera: a323a5942b5e7fc8349e17d728e91c18840ad561
EXFileSystem: 844e86ca9b5375486ecc4ef06d3838d5597d895d
Expo: 863488a600a4565698a79577117c70b170054d08
ExpoAppleAuthentication: 7bd5e4150d59e8df37aa80b425850ae88adf9e65

View File

@ -48,7 +48,7 @@
"@react-native-community/blur": "^4.1.0",
"@react-native-community/cameraroll": "4.1.2",
"@react-native-community/datetimepicker": "^6.7.5",
"@react-native-community/hooks": "2.6.0",
"@react-native-community/hooks": "3.0.0",
"@react-native-community/netinfo": "6.0.0",
"@react-native-community/picker": "^1.8.1",
"@react-native-community/slider": "^4.4.2",
@ -73,6 +73,7 @@
"expo": "^48.0.9",
"expo-apple-authentication": "^6.0.1",
"expo-av": "^13.2.1",
"expo-camera": "^13.2.1",
"expo-file-system": "^15.2.2",
"expo-haptics": "^12.2.1",
"expo-keep-awake": "^12.0.1",
@ -144,6 +145,7 @@
"rn-root-view": "RocketChat/rn-root-view",
"semver": "^7.3.8",
"transliteration": "^2.3.5",
"typed-redux-saga": "^1.5.0",
"ua-parser-js": "^1.0.32",
"uri-js": "^4.4.1",
"url-parse": "1.5.10",

View File

@ -0,0 +1,49 @@
diff --git a/node_modules/react-native-notifier/src/Notifier.tsx b/node_modules/react-native-notifier/src/Notifier.tsx
index 56c5819..4f31e78 100644
--- a/node_modules/react-native-notifier/src/Notifier.tsx
+++ b/node_modules/react-native-notifier/src/Notifier.tsx
@@ -44,6 +44,7 @@ export class NotifierRoot extends React.PureComponent<ShowNotificationParams, St
Component: NotificationComponent,
swipeEnabled: DEFAULT_SWIPE_ENABLED,
componentProps: {},
+ visible: false,
};
this.isShown = false;
this.isHiding = false;
@@ -146,6 +147,7 @@ export class NotifierRoot extends React.PureComponent<ShowNotificationParams, St
Component: Component ?? NotificationComponent,
swipeEnabled: swipeEnabled ?? DEFAULT_SWIPE_ENABLED,
componentProps: componentProps,
+ visible: true,
});
this.showParams = restParams;
@@ -188,6 +190,7 @@ export class NotifierRoot extends React.PureComponent<ShowNotificationParams, St
}
private onHidden() {
+ this.setState({ visible: false })
this.showParams?.onHidden?.();
this.isShown = false;
this.isHiding = false;
@@ -259,7 +262,7 @@ export class NotifierRoot extends React.PureComponent<ShowNotificationParams, St
>
<TouchableWithoutFeedback onPress={this.onPress}>
<View onLayout={this.onLayout}>
- <Component title={title} description={description} {...componentProps} />
+ {this.state.visible? <Component title={title} description={description} {...componentProps} /> : null}
</View>
</TouchableWithoutFeedback>
</Animated.View>
diff --git a/node_modules/react-native-notifier/src/types.ts b/node_modules/react-native-notifier/src/types.ts
index 229e19c..e16a943 100644
--- a/node_modules/react-native-notifier/src/types.ts
+++ b/node_modules/react-native-notifier/src/types.ts
@@ -95,6 +95,7 @@ export interface StateInterface {
swipeEnabled: boolean;
Component: ElementType;
componentProps: Record<string, any>;
+ visible: boolean;
}
export interface NotifierInterface {

View File

@ -4,7 +4,7 @@
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
"target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
// "lib": [], /* Specify library files to be included in the compilation. */
"allowJs": true /* Allow javascript files to be compiled. */,

View File

@ -830,6 +830,13 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-module-imports@^7.14.5", "@babel/helper-module-imports@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-module-imports@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437"
@ -837,13 +844,6 @@
dependencies:
"@babel/types" "^7.16.7"
"@babel/helper-module-imports@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-module-transforms@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.10.4.tgz#ca1f01fdb84e48c24d7506bb818c961f1da8805d"
@ -4476,6 +4476,13 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@koale/useworker@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@koale/useworker/-/useworker-4.0.2.tgz#cb540a2581cd6025307c3ca6685bc60748773e58"
integrity sha512-xPIPADtom8/3/4FLNj7MvNcBM/Z2FleH85Fdx2O869eoKW8+PoEgtSVvoxWjCWMA46Sm9A5/R1TyzNGc+yM0wg==
dependencies:
dequal "^1.0.0"
"@mdx-js/mdx@^1.6.22":
version "1.6.22"
resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba"
@ -4814,10 +4821,10 @@
dependencies:
invariant "^2.2.4"
"@react-native-community/hooks@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@react-native-community/hooks/-/hooks-2.6.0.tgz#dd5f19601eb3684c6bcdd3df3d0c04cf44c24cff"
integrity sha512-emBGKvhJ0h++lLJQ5ejsj+od9G67nEaihjvfSx7/JWvNrQGAhP9U0OZqgb9dkKzor9Ufaj9SGt8RNY97cGzttw==
"@react-native-community/hooks@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@react-native-community/hooks/-/hooks-3.0.0.tgz#af5f2ca32eea59b792ce9e3d9a4cf0354f9b195f"
integrity sha512-g2OyxXHfwIytXUJitBR6Z/ISoOfp0WKx5FOv+NqJ/CrWjRDcTw6zXE5I1C9axfuh30kJqzWchVfCDrkzZYTxqg==
"@react-native-community/netinfo@6.0.0":
version "6.0.0"
@ -5008,7 +5015,7 @@
"@rocket.chat/sdk@RocketChat/Rocket.Chat.js.SDK#mobile":
version "1.3.0-mobile"
resolved "https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/454b4ba784095057b8de862eb99340311b672e15"
resolved "https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ad71e7daa5bcb1a3b457b5de20fb0fc86581d04d"
dependencies:
js-sha256 "^0.9.0"
lru-cache "^4.1.1"
@ -7316,7 +7323,7 @@ babel-plugin-macros@^2.0.0, babel-plugin-macros@^2.8.0:
cosmiconfig "^6.0.0"
resolve "^1.12.0"
babel-plugin-macros@^3.0.1:
babel-plugin-macros@^3.0.1, babel-plugin-macros@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
integrity sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==
@ -9424,6 +9431,11 @@ deprecated-react-native-prop-types@^3.0.1:
invariant "*"
prop-types "*"
dequal@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.1.tgz#dbbf9795ec626e9da8bd68782f4add1d23700d8b"
integrity sha512-Fx8jxibzkJX2aJgyfSdLhr9tlRoTnHKrRJuu2XHlAgKioN2j19/Bcbe0d4mFXYZ3+wpE2KVobUVTfDutcD17xQ==
dequal@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
@ -10539,6 +10551,14 @@ expo-av@^13.2.1:
resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-13.2.1.tgz#ce502a4c5d4a57962fd9f5f1a40c76c39c88f5ee"
integrity sha512-mC0mYSzaOaZgXjzhW2l4Ag325JjH6q5IbptfwD7gkMOFYy7VPOMxEMUnetadbs3DDzmgE6vUWrTjUIUbwq59qg==
expo-camera@^13.2.1:
version "13.2.1"
resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-13.2.1.tgz#bfd1e2248d10a5da43d43a4cc77e378e5acf25bb"
integrity sha512-fZdRyF402jJGGmLVlumrLcr5Em9+Y2SO1MIlxLBtHXnybyHbTRMRAbzVapKX1Aryfujqadh+Kl+sdsWYkMuJjg==
dependencies:
"@koale/useworker" "^4.0.2"
invariant "^2.2.4"
expo-constants@~14.2.0, expo-constants@~14.2.1:
version "14.2.1"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-14.2.1.tgz#b5b6b8079d2082c31ccf2cbc7cf97a0e83c229c3"
@ -19827,6 +19847,14 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typed-redux-saga@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/typed-redux-saga/-/typed-redux-saga-1.5.0.tgz#f70b47c92c6e29e0184d0c30d563c18d6ad0ae54"
integrity sha512-XHKliNtRNUegYAAztbVDb5Q+FMqYNQPaed6Xq2N8kz8AOmiOCVxW3uIj7TEptR1/ms6M9u3HEDfJr4qqz/PYrw==
optionalDependencies:
"@babel/helper-module-imports" "^7.14.5"
babel-plugin-macros "^3.1.0"
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"