diff --git a/android/build.gradle b/android/build.gradle index 45502e43a..2d0cab4d1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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" + } } } diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts index 75f42588c..486987622 100644 --- a/app/actions/actionsTypes.ts +++ b/app/actions/actionsTypes.ts @@ -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' +]); diff --git a/app/actions/videoConf.ts b/app/actions/videoConf.ts new file mode 100644 index 000000000..b78eba5a2 --- /dev/null +++ b/app/actions/videoConf.ts @@ -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 + }; +} diff --git a/app/containers/ActionSheet/Provider.tsx b/app/containers/ActionSheet/Provider.tsx index 4b21726bc..544fc180f 100644 --- a/app/containers/ActionSheet/Provider.tsx +++ b/app/containers/ActionSheet/Provider.tsx @@ -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): typeof Com return WithActionSheetComponent; }; -export const ActionSheetProvider = React.memo(({ children }: { children: React.ReactElement | React.ReactElement[] }) => { - const ref: ForwardedRef = useRef(null); +const actionSheetRef: React.Ref = 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 ( - + <>{children} ); }); + +export const hideActionSheetRef = (): void => { + actionSheetRef?.current?.hideActionSheet(); +}; diff --git a/app/containers/CallHeader.tsx b/app/containers/CallHeader.tsx new file mode 100644 index 000000000..713f27cbc --- /dev/null +++ b/app/containers/CallHeader.tsx @@ -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 ( + + + + {title} + {calling && direct ? : null} + + + setCam(!cam)} + style={[style.iconCallContainerRight, { backgroundColor: handleColors(cam).button }]} + hitSlop={BUTTON_HIT_SLOP} + disabled={calling} + > + + + setMic(!mic)} + style={[style.iconCallContainer, { backgroundColor: handleColors(mic).button }]} + hitSlop={BUTTON_HIT_SLOP} + disabled={calling} + > + + + + + + + {direct ? : null} + + {name} + + + + ); +}; + +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; +} diff --git a/app/containers/DotsLoader/index.tsx b/app/containers/DotsLoader/index.tsx new file mode 100644 index 000000000..7dd534534 --- /dev/null +++ b/app/containers/DotsLoader/index.tsx @@ -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 = { + height: SIZE, + width: SIZE, + borderRadius: SIZE / 2, + marginHorizontal: MARGIN, + backgroundColor: active ? colors.dotActiveBg : colors.dotBg + }; + + return ; +} + +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 ( + + {dots.map(i => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + dotsContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginLeft: 6 } +}); + +export default DotsLoader; diff --git a/app/containers/InAppNotification/IncomingCallNotification/index.tsx b/app/containers/InAppNotification/IncomingCallNotification/index.tsx new file mode 100644 index 000000000..0195da813 --- /dev/null +++ b/app/containers/InAppNotification/IncomingCallNotification/index.tsx @@ -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; + 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 ( + + + + + + + { + hideNotification(); + dispatch(cancelCall({ callId })); + }} + style={styles.cancelButton} + > + {i18n.t('decline')} + + { + hideNotification(); + dispatch(acceptCall({ callId })); + }} + style={styles.acceptButton} + > + {i18n.t('accept')} + + + + + ); + } +); + +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 ; + } + return null; +}; + +export default IncomingCallNotification; diff --git a/app/containers/InAppNotification/IncomingCallNotification/style.tsx b/app/containers/InAppNotification/IncomingCallNotification/style.tsx new file mode 100644 index 000000000..81cfcead4 --- /dev/null +++ b/app/containers/InAppNotification/IncomingCallNotification/style.tsx @@ -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' + } + }); +}; diff --git a/app/containers/InAppNotification/NotifierComponent.tsx b/app/containers/InAppNotification/NotifierComponent.tsx index 8b32ae1c6..2db7f6908 100644 --- a/app/containers/InAppNotification/NotifierComponent.tsx +++ b/app/containers/InAppNotification/NotifierComponent.tsx @@ -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; 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(); diff --git a/app/containers/InAppNotification/index.tsx b/app/containers/InAppNotification/index.tsx index d6581f27f..8337f8f3c 100644 --- a/app/containers/InAppNotification/index.tsx +++ b/app/containers/InAppNotification/index.tsx @@ -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 }); } }; diff --git a/app/containers/Ringer/dialtone.mp3 b/app/containers/Ringer/dialtone.mp3 new file mode 100644 index 000000000..5a6aa99f3 Binary files /dev/null and b/app/containers/Ringer/dialtone.mp3 differ diff --git a/app/containers/Ringer/index.tsx b/app/containers/Ringer/index.tsx new file mode 100644 index 000000000..2b91c7943 --- /dev/null +++ b/app/containers/Ringer/index.tsx @@ -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(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 ; +}); + +export default Ringer; diff --git a/app/containers/Ringer/ringtone.mp3 b/app/containers/Ringer/ringtone.mp3 new file mode 100644 index 000000000..6be6a7456 Binary files /dev/null and b/app/containers/Ringer/ringtone.mp3 differ diff --git a/app/containers/RoomItem/LastMessage.tsx b/app/containers/RoomItem/LastMessage.tsx index 66fe0ab5a..1c660e856 100644 --- a/app/containers/RoomItem/LastMessage.tsx +++ b/app/containers/RoomItem/LastMessage.tsx @@ -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}`; }; diff --git a/app/containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet.tsx b/app/containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet.tsx deleted file mode 100644 index f9ecd88d9..000000000 --- a/app/containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet.tsx +++ /dev/null @@ -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 ( - - - {i18n.t('Start_a_call')} - - setCam(!cam)} - style={[style.iconCallContainer, cam && style.enabledBackground, { marginRight: 6 }]} - hitSlop={BUTTON_HIT_SLOP} - > - - - setMic(!mic)} - style={[style.iconCallContainer, mic && style.enabledBackground]} - hitSlop={BUTTON_HIT_SLOP} - > - - - - - - - - - {user.username} - - - - - -