init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { setGoogleAPIState } from '../../../../google-api/actions';
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.native';
import {
GOOGLE_API_STATES,
GOOGLE_SCOPE_YOUTUBE
} from '../../../../google-api/constants';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import logger from '../../../logger';
import styles from './styles';
/**
* Prop type of the component {@code GoogleSigninForm}.
*/
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The Redux dispatch Function.
*/
dispatch: IStore['dispatch'];
/**
* The current state of the Google api as defined in {@code constants.js}.
*/
googleAPIState: number;
/**
* The recently received Google response.
*/
googleResponse: any;
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*/
onUserChanged: Function;
}
/**
* Class to render a google sign in form, or a google stream picker dialog.
*
* @augments Component
*/
class GoogleSigninForm extends Component<IProps> {
/**
* Instantiates a new {@code GoogleSigninForm} component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._logGoogleError = this._logGoogleError.bind(this);
this._onGoogleButtonPress = this._onGoogleButtonPress.bind(this);
}
/**
* Implements React's Component.componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
googleApi.hasPlayServices()
.then(() => {
googleApi.configure({
offlineAccess: false,
scopes: [ GOOGLE_SCOPE_YOUTUBE ]
});
googleApi.signInSilently().then((response: any) => {
this._setApiState(response
? GOOGLE_API_STATES.SIGNED_IN
: GOOGLE_API_STATES.LOADED,
response);
}, () => {
this._setApiState(GOOGLE_API_STATES.LOADED);
});
})
.catch((error: Error) => {
this._logGoogleError(error);
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
});
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, t } = this.props;
const { googleAPIState, googleResponse } = this.props;
const signedInUser = googleResponse?.user?.email;
if (googleAPIState === GOOGLE_API_STATES.NOT_AVAILABLE
|| googleAPIState === GOOGLE_API_STATES.NEEDS_LOADING
|| typeof googleAPIState === 'undefined') {
return null;
}
const userInfo = signedInUser
? `${t('liveStreaming.signedInAs')} ${signedInUser}`
: t('liveStreaming.signInCTA');
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.helpText as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ userInfo }
</Text>
</View>
<GoogleSignInButton
onClick = { this._onGoogleButtonPress }
signedIn = {
googleAPIState === GOOGLE_API_STATES.SIGNED_IN } />
</View>
);
}
/**
* A helper function to log developer related errors.
*
* @private
* @param {Object} error - The error to be logged.
* @returns {void}
*/
_logGoogleError(error: Error) {
// NOTE: This is a developer error message, not intended for the
// user to see.
logger.error('Google API error. Possible cause: bad config.', error);
}
/**
* Callback to be invoked when the user presses the Google button,
* regardless of being logged in or out.
*
* @private
* @returns {void}
*/
_onGoogleButtonPress() {
const { googleResponse } = this.props;
if (googleResponse?.user) {
// the user is signed in
this._onSignOut();
} else {
this._onSignIn();
}
}
/**
* Initiates a sign in if the user is not signed in yet.
*
* @private
* @returns {void}
*/
_onSignIn() {
googleApi.signIn().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.SIGNED_IN, response);
}, this._logGoogleError);
}
/**
* Initiates a sign out if the user is signed in.
*
* @private
* @returns {void}
*/
_onSignOut() {
googleApi.signOut().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.LOADED, response);
}, this._logGoogleError);
}
/**
* Updates the API (Google Auth) state.
*
* @private
* @param {number} apiState - The state of the API.
* @param {?Object} googleResponse - The response from the API.
* @returns {void}
*/
_setApiState(apiState: number, googleResponse?: Object) {
this.props.onUserChanged(googleResponse);
this.props.dispatch(setGoogleAPIState(apiState, googleResponse));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code GoogleSigninForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* googleAPIState: number,
* googleResponse: Object
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { googleAPIState, googleResponse } = state['features/google-api'];
return {
..._abstractMapStateToProps(state),
googleAPIState,
googleResponse
};
}
export default translate(connect(_mapStateToProps)(GoogleSigninForm));

View File

@@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { LIVE_STREAMING_ENABLED } from '../../../../base/flags/constants';
import { getFeatureFlag } from '../../../../base/flags/functions';
import { translate } from '../../../../base/i18n/functions';
import { navigate }
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../../mobile/navigation/routes';
import AbstractLiveStreamButton, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractLiveStreamButton';
import { IProps } from '../AbstractStartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
type Props = IProps & AbstractProps;
/**
* Button for opening the live stream settings screen.
*/
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_onHandleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
if (_isLiveStreamRunning) {
dispatch(openDialog(StopLiveStreamDialog));
} else {
navigate(screen.conference.liveStream);
}
}
}
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component
* instance.
* @private
* @returns {Props}
*/
export function mapStateToProps(state: IReduxState, ownProps: any) {
const enabled = getFeatureFlag(state, LIVE_STREAMING_ENABLED, true);
const abstractProps = _abstractMapStateToProps(state, ownProps);
return {
...abstractProps,
visible: enabled && abstractProps.visible
};
}
export default translate(connect(mapStateToProps)(LiveStreamButton));

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../../base/styles/functions.any';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import HeaderNavigationButton
from '../../../../mobile/navigation/components/HeaderNavigationButton';
import { goBack }
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setLiveStreamKey } from '../../../actions';
import AbstractStartLiveStreamDialog, { IProps, _mapStateToProps } from '../AbstractStartLiveStreamDialog';
import GoogleSigninForm from './GoogleSigninForm';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
import styles from './styles';
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*/
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onStartPress = this._onStartPress.bind(this);
this._onStreamKeyChangeNative
= this._onStreamKeyChangeNative.bind(this);
this._onStreamKeyPick = this._onStreamKeyPick.bind(this);
this._onUserChanged = this._onUserChanged.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerRight: () => (
<HeaderNavigationButton
label = { t('dialog.start') }
onPress = { this._onStartPress }
twoActions = { true } />
)
});
}
/**
* Starts live stream session and goes back to the previous screen.
*
* @returns {void}
*/
_onStartPress() {
this._onSubmit() && goBack();
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
return (
<JitsiScreen style = { styles.startLiveStreamContainer as StyleType }>
<GoogleSigninForm
onUserChanged = { this._onUserChanged } />
<StreamKeyPicker
broadcasts = { this.state.broadcasts }
onChange = { this._onStreamKeyPick } />
<StreamKeyForm
onChange = { this._onStreamKeyChangeNative }
value = {
this.state.streamKey || this.props._streamKey || ''
} />
</JitsiScreen>
);
}
/**
* Callback to handle stream key changes.
*
* FIXME: This is a temporary method to store the streaming key on mobile
* for easier use, until the Google sign-in is implemented. We don't store
* the key on web for security reasons (e.g. We don't want to have the key
* stored if the used signed out).
*
* @private
* @param {string} streamKey - The new key value.
* @returns {void}
*/
_onStreamKeyChangeNative(streamKey: string) {
this.props.dispatch(setLiveStreamKey(streamKey));
this._onStreamKeyChange(streamKey);
}
/**
* Callback to be invoked when the user selects a stream from the picker.
*
* @private
* @param {string} streamKey - The key of the selected stream.
* @returns {void}
*/
_onStreamKeyPick(streamKey: string) {
this.setState({
streamKey
});
}
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*
* TODO: Handle errors by showing some indication to the user.
*
* @private
* @param {Object} response - The retrieved signin response.
* @returns {void}
*/
_onUserChanged(response: Object) {
if (response) {
googleApi.getTokens()
.then((tokens: any) => {
googleApi.getYouTubeLiveStreams(tokens.accessToken)
.then((broadcasts: any) => {
this.setState({
broadcasts
});
});
})
.catch(() => {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
});
} else {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
}
}
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../../base/i18n/functions';
import AbstractStopLiveStreamDialog, {
_mapStateToProps
} from '../AbstractStopLiveStreamDialog';
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
descriptionKey = 'dialog.stopStreamingWarning'
onSubmit = { this._onSubmit } />
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { Linking, Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import Button from '../../../../base/ui/components/native/Button';
import Input from '../../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
import AbstractStreamKeyForm, {
IProps as AbstractProps
} from '../AbstractStreamKeyForm';
import { getLiveStreaming } from '../functions';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
}
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @augments Component
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Initializes a new {@code StreamKeyForm} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onOpenGooglePrivacyPolicy = this._onOpenGooglePrivacyPolicy.bind(this);
this._onOpenHelp = this._onOpenHelp.bind(this);
this._onOpenYoutubeTerms = this._onOpenYoutubeTerms.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _dialogStyles, t } = this.props;
return (
<>
<View style = { styles.formWrapper as ViewStyle }>
<Input
customStyles = {{
input: styles.streamKeyInput,
container: styles.streamKeyContainer }}
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
value = { this.props.value } />
<View style = { styles.formValidationItem as ViewStyle }>
{
this.state.showValidationError && <Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ t('liveStreaming.invalidStreamKey') }
</Text>
}
</View>
</View>
<View style = { styles.formButtonsWrapper as ViewStyle }>
<Button
accessibilityLabel = 'liveStreaming.streamIdHelp'
labelKey = 'liveStreaming.streamIdHelp'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenHelp }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.youtubeTerms'
labelKey = 'liveStreaming.youtubeTerms'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenYoutubeTerms }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.googlePrivacyPolicy'
labelKey = 'liveStreaming.googlePrivacyPolicy'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenGooglePrivacyPolicy }
type = { BUTTON_TYPES.TERTIARY } />
</View>
</>
);
}
/**
* Opens the Google Privacy Policy web page.
*
* @private
* @returns {void}
*/
_onOpenGooglePrivacyPolicy() {
const url = this.props._liveStreaming.dataPrivacyURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the information link on how to manually locate a YouTube broadcast
* stream key.
*
* @private
* @returns {void}
*/
_onOpenHelp() {
const url = this.props._liveStreaming.helpURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the YouTube terms and conditions web page.
*
* @private
* @returns {void}
*/
_onOpenYoutubeTerms() {
const url = this.props._liveStreaming.termsURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code StreamKeyForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _liveStreaming: LiveStreamingProps
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_liveStreaming: getLiveStreaming(state)
};
}
export default translate(connect(_mapStateToProps)(StreamKeyForm));

View File

@@ -0,0 +1,168 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import {
Linking,
Text,
TouchableHighlight,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { connect } from 'react-redux';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
import styles, { ACTIVE_OPACITY, TOUCHABLE_UNDERLAY } from './styles';
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The list of broadcasts the user can pick from.
*/
broadcasts?: Array<{ key: string; title: string; }>;
/**
* Callback to be invoked when the user picked a broadcast. To be invoked
* with a single key (string).
*/
onChange: Function;
}
interface IState {
/**
* The key of the currently selected stream.
*/
streamKey?: string | null;
}
/**
* Class to implement a stream key picker (dropdown) component to allow the user
* to choose from the available Google Broadcasts/Streams.
*
* NOTE: This component is currently only used on mobile, but it is advised at
* a later point to unify mobile and web logic for this functionality. But it's
* out of the scope for now of the mobile live streaming functionality.
*/
class StreamKeyPicker extends Component<IProps, IState> {
/**
* Instantiates a new instance of StreamKeyPicker.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
streamKey: null
};
this._onOpenYoutubeDashboard = this._onOpenYoutubeDashboard.bind(this);
this._onStreamPick = this._onStreamPick.bind(this);
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, broadcasts } = this.props;
if (!broadcasts) {
return null;
}
if (!broadcasts.length) {
return (
<View style = { styles.formWrapper as ViewStyle }>
<TouchableOpacity
onPress = { this._onOpenYoutubeDashboard }>
<Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ this.props.t(
'liveStreaming.getStreamKeyManually') }
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.streamKeyPickerCta as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ this.props.t('liveStreaming.choose') }
</Text>
</View>
<View style = { styles.streamKeyPickerWrapper as ViewStyle } >
{ broadcasts.map((broadcast, index) =>
(<TouchableHighlight
activeOpacity = { ACTIVE_OPACITY }
key = { index }
onPress = { this._onStreamPick(broadcast.key) }
style = { [
styles.streamKeyPickerItem,
this.state.streamKey === broadcast.key
? styles.streamKeyPickerItemHighlight : null
] as ViewStyle[] }
underlayColor = { TOUCHABLE_UNDERLAY }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ broadcast.title }
</Text>
</TouchableHighlight>))
}
</View>
</View>
);
}
/**
* Opens the link which should display the YouTube broadcast live stream
* key.
*
* @private
* @returns {void}
*/
_onOpenYoutubeDashboard() {
Linking.openURL(YOUTUBE_LIVE_DASHBOARD_URL);
}
/**
* Callback to be invoked when the user picks a stream from the list.
*
* @private
* @param {string} streamKey - The key of the stream selected.
* @returns {Function}
*/
_onStreamPick(streamKey: string) {
return () => {
this.setState({
streamKey
});
this.props.onChange(streamKey);
};
}
}
export default translate(
connect(_abstractMapStateToProps)(StreamKeyPicker));

View File

@@ -0,0 +1,146 @@
import { createStyleSheet } from '../../../../base/styles/functions.native';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* Opacity of the TouchableHighlight.
*/
export const ACTIVE_OPACITY = 0.3;
/**
* Underlay of the TouchableHighlight.
*/
export const TOUCHABLE_UNDERLAY = BaseTheme.palette.ui06;
/**
* The styles of the React {@code Components} of LiveStream.
*/
export default createStyleSheet({
/**
* Generic component to wrap form sections into achieving a unified look.
*/
formWrapper: {
alignItems: 'stretch',
flexDirection: 'column',
paddingHorizontal: BaseTheme.spacing[2]
},
formValidationItem: {
alignSelf: 'flex-start',
flexDirection: 'row',
height: BaseTheme.spacing[4],
marginTop: BaseTheme.spacing[2]
},
formButtonsWrapper: {
alignSelf: 'center',
display: 'flex',
maxWidth: 200
},
buttonLabelStyle: {
color: BaseTheme.palette.link01
},
/**
* Explaining text on the top of the sign in form.
*/
helpText: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Container for the live stream screen.
*/
startLiveStreamContainer: {
backgroundColor: BaseTheme.palette.ui01,
display: 'flex',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[3]
},
/**
* Helper link text.
*/
streamKeyHelp: {
alignSelf: 'flex-end'
},
/**
* Input field to manually enter stream key.
*/
streamKeyInput: {
alignSelf: 'stretch',
color: BaseTheme.palette.text01,
textAlign: 'left'
},
streamKeyContainer: {
marginTop: BaseTheme.spacing[3]
},
/**
* Custom component to pick a broadcast from the list fetched from Google.
*/
streamKeyPicker: {
alignSelf: 'stretch',
flex: 1,
height: 40,
marginHorizontal: BaseTheme.spacing[1],
width: 300
},
/**
* CTA (label) of the picker.
*/
streamKeyPickerCta: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Style of a single item in the list.
*/
streamKeyPickerItem: {
padding: BaseTheme.spacing[1]
},
/**
* Additional style for the selected item.
*/
streamKeyPickerItemHighlight: {
backgroundColor: BaseTheme.palette.ui04
},
/**
* Overall wrapper for the picker.
*/
streamKeyPickerWrapper: {
borderColor: BaseTheme.palette.ui07,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 1,
flexDirection: 'column'
},
/**
* Terms and Conditions texts.
*/
tcText: {
textAlign: 'right'
},
text: {
color: BaseTheme.palette.text01,
fontSize: 14,
textAlign: 'left'
},
/**
* A different colored text to indicate information needing attention.
*/
warningText: {
color: BaseTheme.palette.warning02
}
});