[Android] Introduce IncomingCallView
It's a separate view (on the native side) and app (on the JavaScript side) so applications can use it independently. Co-authored-by: Shuai Li <sli@atlassian.com> Co-authored-by: Pawel Domas <pawel.domas@jitsi.org>
This commit is contained in:
committed by
Lyubo Marinov
parent
39e236a42c
commit
ea22d12581
27
react/features/mobile/incoming-call/actionTypes.js
Normal file
27
react/features/mobile/incoming-call/actionTypes.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The type of redux action to answer an incoming call.
|
||||
*
|
||||
* {
|
||||
* type: INCOMING_CALL_ANSWERED,
|
||||
* }
|
||||
*/
|
||||
export const INCOMING_CALL_ANSWERED = Symbol('INCOMING_CALL_ANSWERED');
|
||||
|
||||
/**
|
||||
* The type of redux action to decline an incoming call.
|
||||
*
|
||||
* {
|
||||
* type: INCOMING_CALL_DECLINED,
|
||||
* }
|
||||
*/
|
||||
export const INCOMING_CALL_DECLINED = Symbol('INCOMING_CALL_DECLINED');
|
||||
|
||||
/**
|
||||
* The type of redux action to receive an incoming call.
|
||||
*
|
||||
* {
|
||||
* type: INCOMING_CALL_RECEIVED,
|
||||
* caller: Object
|
||||
* }
|
||||
*/
|
||||
export const INCOMING_CALL_RECEIVED = Symbol('INCOMING_CALL_RECEIVED');
|
||||
49
react/features/mobile/incoming-call/actions.js
Normal file
49
react/features/mobile/incoming-call/actions.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// @flow
|
||||
|
||||
import {
|
||||
INCOMING_CALL_ANSWERED,
|
||||
INCOMING_CALL_DECLINED,
|
||||
INCOMING_CALL_RECEIVED
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Answers a received incoming call.
|
||||
*
|
||||
* @returns {{
|
||||
* type: INCOMING_CALL_ANSWERED
|
||||
* }}
|
||||
*/
|
||||
export function incomingCallAnswered() {
|
||||
return {
|
||||
type: INCOMING_CALL_ANSWERED
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Declines a received incoming call.
|
||||
*
|
||||
* @returns {{
|
||||
* type: INCOMING_CALL_DECLINED
|
||||
* }}
|
||||
*/
|
||||
export function incomingCallDeclined() {
|
||||
return {
|
||||
type: INCOMING_CALL_DECLINED
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a received incoming call.
|
||||
*
|
||||
* @param {Object} caller - The caller of an incoming call.
|
||||
* @returns {{
|
||||
* type: INCOMING_CALL_RECEIVED,
|
||||
* caller: Object
|
||||
* }}
|
||||
*/
|
||||
export function incomingCallReceived(caller: Object) {
|
||||
return {
|
||||
type: INCOMING_CALL_RECEIVED,
|
||||
caller
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { AbstractButton } from '../../../base/toolbox';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import type { AbstractButtonProps } from '../../../base/toolbox';
|
||||
|
||||
import { incomingCallAnswered } from '../actions';
|
||||
|
||||
type Props = AbstractButtonProps & {
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of a button which accepts an incoming call.
|
||||
*/
|
||||
class AnswerButton extends AbstractButton<Props, *> {
|
||||
accessibilityLabel = 'incomingCall.answer';
|
||||
iconName = 'hangup';
|
||||
label = 'incomingCall.answer';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and answers the incoming call.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClick() {
|
||||
this.props.dispatch(incomingCallAnswered());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default translate(connect()(AnswerButton));
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { AbstractButton } from '../../../base/toolbox';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import type { AbstractButtonProps } from '../../../base/toolbox';
|
||||
|
||||
import { incomingCallDeclined } from '../actions';
|
||||
|
||||
type Props = AbstractButtonProps & {
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of a button which rejects an incoming call.
|
||||
*/
|
||||
class DeclineButton extends AbstractButton<Props, *> {
|
||||
accessibilityLabel = 'incomingCall.decline';
|
||||
iconName = 'hangup';
|
||||
label = 'incomingCall.decline';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and declines the incoming call.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClick() {
|
||||
this.props.dispatch(incomingCallDeclined());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default translate(connect()(DeclineButton));
|
||||
@@ -0,0 +1,63 @@
|
||||
// @flow
|
||||
|
||||
import { BaseApp } from '../../../base/app';
|
||||
|
||||
import { incomingCallReceived } from '../actions';
|
||||
|
||||
import IncomingCallPage from './IncomingCallPage';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* URL of the avatar for the caller.
|
||||
*/
|
||||
callerAvatarUrl: string,
|
||||
|
||||
/**
|
||||
* Name of the caller.
|
||||
*/
|
||||
callerName: string,
|
||||
|
||||
/**
|
||||
* Whether this is a video call or not.
|
||||
*/
|
||||
hasVideo: boolean
|
||||
};
|
||||
|
||||
/**
|
||||
* Root application component for incoming call.
|
||||
*
|
||||
* @extends BaseApp
|
||||
*/
|
||||
export default class IncomingCallApp extends BaseApp<Props> {
|
||||
_init: Promise<*>;
|
||||
|
||||
/**
|
||||
* Navigates to {@link IncomingCallPage} upon mount.
|
||||
*
|
||||
* NOTE: This was implmented here instead of in a middleware for
|
||||
* the APP_WILL_MOUNT action because that would run also for {@link App}.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillMount() {
|
||||
super.componentWillMount();
|
||||
|
||||
this._init.then(() => {
|
||||
const { dispatch } = this.state.store;
|
||||
const {
|
||||
callerAvatarUrl: avatarUrl,
|
||||
callerName: name,
|
||||
hasVideo
|
||||
} = this.props;
|
||||
|
||||
dispatch(incomingCallReceived({
|
||||
avatarUrl,
|
||||
name,
|
||||
hasVideo
|
||||
}));
|
||||
|
||||
super._navigate({ component: IncomingCallPage });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// @flow
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { Avatar } from '../../../base/participants';
|
||||
|
||||
import AnswerButton from './AnswerButton';
|
||||
import DeclineButton from './DeclineButton';
|
||||
import styles, {
|
||||
AVATAR_BORDER_GRADIENT,
|
||||
BACKGROUND_OVERLAY_GRADIENT,
|
||||
CALLER_AVATAR_SIZE
|
||||
} from './styles';
|
||||
|
||||
/**
|
||||
* The type of React {@code Component} props of {@link IncomingCallPage}.
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Caller's avatar URL.
|
||||
*/
|
||||
_callerAvatarUrl: string,
|
||||
|
||||
/**
|
||||
* Caller's name.
|
||||
*/
|
||||
_callerName: string,
|
||||
|
||||
/**
|
||||
* Whether the call has video or not.
|
||||
*/
|
||||
_hasVideo: boolean,
|
||||
|
||||
/**
|
||||
* Helper for translating strings.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* The React {@code Component} displays an incoming call screen.
|
||||
*/
|
||||
class IncomingCallPage extends Component<Props> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { t, _callerName, _hasVideo } = this.props;
|
||||
const callTitle
|
||||
= _hasVideo
|
||||
? t('incomingCall.videoCallTitle')
|
||||
: t('incomingCall.audioCallTitle');
|
||||
|
||||
return (
|
||||
<View style = { styles.pageContainer }>
|
||||
<View style = { styles.backgroundAvatar }>
|
||||
<Image
|
||||
source = {{ uri: this.props._callerAvatarUrl }}
|
||||
style = { styles.backgroundAvatarImage } />
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors = { BACKGROUND_OVERLAY_GRADIENT }
|
||||
style = { styles.backgroundOverlayGradient } />
|
||||
<Text style = { styles.title }>
|
||||
{ callTitle }
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines = { 6 }
|
||||
style = { styles.callerName } >
|
||||
{ _callerName }
|
||||
</Text>
|
||||
<Text style = { styles.productLabel }>
|
||||
{ t('incomingCall.productLabel') }
|
||||
</Text>
|
||||
{ this._renderCallerAvatar() }
|
||||
{ this._renderButtons() }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders caller avatar.
|
||||
*
|
||||
* @private
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderCallerAvatar() {
|
||||
return (
|
||||
<View style = { styles.avatarContainer }>
|
||||
<LinearGradient
|
||||
colors = { AVATAR_BORDER_GRADIENT }
|
||||
style = { styles.avatarBorder } />
|
||||
<View style = { styles.avatar }>
|
||||
<Avatar
|
||||
size = { CALLER_AVATAR_SIZE }
|
||||
uri = { this.props._callerAvatarUrl } />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders buttons.
|
||||
*
|
||||
* @private
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderButtons() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.buttonsContainer }>
|
||||
<View style = { styles.buttonWrapper } >
|
||||
<DeclineButton
|
||||
styles = { styles.declineButtonStyles } />
|
||||
<Text style = { styles.buttonText }>
|
||||
{ t('incomingCall.decline') }
|
||||
</Text>
|
||||
</View>
|
||||
<View style = { styles.buttonWrapper }>
|
||||
<AnswerButton
|
||||
styles = { styles.answerButtonStyles } />
|
||||
<Text style = { styles.buttonText }>
|
||||
{ t('incomingCall.answer') }
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the component's props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The component's own props.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _callerName: string,
|
||||
* _callerAvatarUrl: string,
|
||||
* _hasVideo: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { caller } = state['features/mobile/incoming-call'] || {};
|
||||
|
||||
return {
|
||||
/**
|
||||
* The caller's avatar url.
|
||||
*
|
||||
* @private
|
||||
* @type {string}
|
||||
*/
|
||||
_callerAvatarUrl: caller.avatarUrl,
|
||||
|
||||
/**
|
||||
* The caller's name.
|
||||
*
|
||||
* @private
|
||||
* @type {string}
|
||||
*/
|
||||
_callerName: caller.name,
|
||||
|
||||
/**
|
||||
* Whether the call has video or not.
|
||||
*
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
_hasVideo: caller.hasVideo
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(IncomingCallPage));
|
||||
2
react/features/mobile/incoming-call/components/index.js
Normal file
2
react/features/mobile/incoming-call/components/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as IncomingCallApp } from './IncomingCallApp';
|
||||
export { default as IncomingCallPage } from './IncomingCallPage';
|
||||
149
react/features/mobile/incoming-call/components/styles.js
Normal file
149
react/features/mobile/incoming-call/components/styles.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
ColorPalette,
|
||||
createStyleSheet
|
||||
} from '../../../base/styles';
|
||||
|
||||
export const AVATAR_BORDER_GRADIENT = [ '#4C9AFF', '#0052CC' ];
|
||||
|
||||
export const BACKGROUND_OVERLAY_GRADIENT = [ '#0052CC', '#4C9AFF' ];
|
||||
|
||||
const BUTTON_SIZE = 56;
|
||||
|
||||
export const CALLER_AVATAR_SIZE = 128;
|
||||
|
||||
const CALLER_AVATAR_BORDER_WIDTH = 3;
|
||||
|
||||
const CALLER_AVATAR_CIRCLE_SIZE
|
||||
= CALLER_AVATAR_SIZE + (2 * CALLER_AVATAR_BORDER_WIDTH);
|
||||
|
||||
const PAGE_PADDING = 48;
|
||||
|
||||
const LINE_SPACING = 8;
|
||||
|
||||
const _icon = {
|
||||
alignSelf: 'center',
|
||||
color: ColorPalette.white,
|
||||
fontSize: 32
|
||||
};
|
||||
|
||||
const _responseButton = {
|
||||
alignSelf: 'center',
|
||||
borderRadius: BUTTON_SIZE / 2,
|
||||
borderWidth: 0,
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
height: BUTTON_SIZE,
|
||||
justifyContent: 'center',
|
||||
width: BUTTON_SIZE
|
||||
};
|
||||
|
||||
const _text = {
|
||||
color: ColorPalette.white,
|
||||
fontSize: 16
|
||||
};
|
||||
|
||||
export default createStyleSheet({
|
||||
answerButtonStyles: {
|
||||
iconStyle: {
|
||||
..._icon,
|
||||
transform: [
|
||||
{ rotateZ: '130deg' }
|
||||
]
|
||||
},
|
||||
style: {
|
||||
..._responseButton,
|
||||
backgroundColor: ColorPalette.green
|
||||
},
|
||||
underlayColor: ColorPalette.buttonUnderlay
|
||||
},
|
||||
|
||||
avatar: {
|
||||
position: 'absolute',
|
||||
marginLeft: CALLER_AVATAR_BORDER_WIDTH,
|
||||
marginTop: CALLER_AVATAR_BORDER_WIDTH
|
||||
},
|
||||
|
||||
avatarBorder: {
|
||||
borderRadius: CALLER_AVATAR_CIRCLE_SIZE / 2,
|
||||
height: CALLER_AVATAR_CIRCLE_SIZE,
|
||||
position: 'absolute',
|
||||
width: CALLER_AVATAR_CIRCLE_SIZE
|
||||
},
|
||||
|
||||
avatarContainer: {
|
||||
height: CALLER_AVATAR_CIRCLE_SIZE,
|
||||
marginTop: LINE_SPACING * 4,
|
||||
width: CALLER_AVATAR_CIRCLE_SIZE
|
||||
},
|
||||
|
||||
backgroundAvatar: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
|
||||
backgroundAvatarImage: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
backgroundOverlayGradient: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
opacity: 0.9,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
|
||||
buttonsContainer: {
|
||||
alignItems: 'flex-end',
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
buttonText: {
|
||||
..._text,
|
||||
alignSelf: 'center',
|
||||
marginTop: 1.5 * LINE_SPACING
|
||||
},
|
||||
|
||||
buttonWrapper: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
callerName: {
|
||||
..._text,
|
||||
fontSize: 36,
|
||||
marginBottom: LINE_SPACING,
|
||||
marginLeft: PAGE_PADDING,
|
||||
marginRight: PAGE_PADDING,
|
||||
marginTop: LINE_SPACING,
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
declineButtonStyles: {
|
||||
iconStyle: _icon,
|
||||
style: {
|
||||
..._responseButton,
|
||||
backgroundColor: ColorPalette.red
|
||||
},
|
||||
underlayColor: ColorPalette.buttonUnderlay
|
||||
},
|
||||
|
||||
pageContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
paddingBottom: PAGE_PADDING,
|
||||
paddingTop: PAGE_PADDING
|
||||
},
|
||||
|
||||
productLabel: {
|
||||
..._text
|
||||
},
|
||||
|
||||
title: {
|
||||
..._text
|
||||
}
|
||||
});
|
||||
4
react/features/mobile/incoming-call/index.js
Normal file
4
react/features/mobile/incoming-call/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './components';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
||||
31
react/features/mobile/incoming-call/middleware.js
Normal file
31
react/features/mobile/incoming-call/middleware.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
|
||||
import { MiddlewareRegistry } from '../../base/redux';
|
||||
import { getSymbolDescription } from '../../base/util';
|
||||
|
||||
import { sendEvent } from '../external-api';
|
||||
|
||||
import {
|
||||
INCOMING_CALL_ANSWERED,
|
||||
INCOMING_CALL_DECLINED
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Middleware that captures Redux actions and uses the IncomingCallExternalAPI
|
||||
* module to turn them into native events so the application knows about them.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case INCOMING_CALL_ANSWERED:
|
||||
case INCOMING_CALL_DECLINED:
|
||||
sendEvent(store, getSymbolDescription(action.type), /* data */ {});
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
26
react/features/mobile/incoming-call/reducer.js
Normal file
26
react/features/mobile/incoming-call/reducer.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/* @flow */
|
||||
|
||||
import { assign, ReducerRegistry } from '../../base/redux';
|
||||
|
||||
import {
|
||||
INCOMING_CALL_ANSWERED,
|
||||
INCOMING_CALL_DECLINED,
|
||||
INCOMING_CALL_RECEIVED
|
||||
} from './actionTypes';
|
||||
|
||||
ReducerRegistry.register(
|
||||
'features/mobile/incoming-call', (state = {}, action) => {
|
||||
switch (action.type) {
|
||||
case INCOMING_CALL_ANSWERED:
|
||||
case INCOMING_CALL_DECLINED:
|
||||
return assign(state, {
|
||||
caller: undefined
|
||||
});
|
||||
case INCOMING_CALL_RECEIVED:
|
||||
return assign(state, {
|
||||
caller: action.caller
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
Reference in New Issue
Block a user