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,137 @@
import { Route } from '@react-navigation/native';
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { Linking, View, ViewStyle } from 'react-native';
import { WebView } from 'react-native-webview';
import { connect } from 'react-redux';
import { IStore } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../../base/react/components/native/LoadingIndicator';
import { getDialInfoPageURLForURIString } from '../../../functions';
import DialInSummaryErrorDialog from './DialInSummaryErrorDialog';
import styles, { INDICATOR_COLOR } from './styles';
interface IProps extends WithTranslation {
dispatch: IStore['dispatch'];
/**
* Default prop for navigating between screen components(React Navigation).
*/
navigation: any;
/**
* Default prop for navigating between screen components(React Navigation).
*/
route: Route<'', { summaryUrl: string; }>;
}
/**
* Implements a React native component that displays the dial in info page for a specific room.
*/
class DialInSummary extends PureComponent<IProps> {
/**
* Initializes a new instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onError = this._onError.bind(this);
this._onNavigate = this._onNavigate.bind(this);
this._renderLoading = this._renderLoading.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after mounting occurs.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerTitle: t('dialIn.screenTitle')
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { route } = this.props;
const summaryUrl = route.params?.summaryUrl;
return (
<JitsiScreen
style = { styles.backDrop }>
<WebView
incognito = { true }
onError = { this._onError }
onShouldStartLoadWithRequest = { this._onNavigate }
renderLoading = { this._renderLoading }
setSupportMultipleWindows = { false }
source = {{ uri: getDialInfoPageURLForURIString(summaryUrl) ?? '' }}
startInLoadingState = { true }
style = { styles.webView }
webviewDebuggingEnabled = { true } />
</JitsiScreen>
);
}
/**
* Callback to handle the error if the page fails to load.
*
* @returns {void}
*/
_onError() {
this.props.dispatch(openDialog(DialInSummaryErrorDialog));
}
/**
* Callback to intercept navigation inside the webview and make the native app handle the dial requests.
*
* NOTE: We don't navigate to anywhere else form that view.
*
* @param {any} request - The request object.
* @returns {boolean}
*/
_onNavigate(request: { url: string; }) {
const { url } = request;
const { route } = this.props;
const summaryUrl = route.params?.summaryUrl;
if (url.startsWith('tel:')) {
Linking.openURL(url);
}
return url === getDialInfoPageURLForURIString(summaryUrl);
}
/**
* Renders the loading indicator.
*
* @returns {React$Component<any>}
*/
_renderLoading() {
return (
<View style = { styles.indicatorWrapper as ViewStyle }>
<LoadingIndicator
color = { INDICATOR_COLOR }
size = 'large' />
</View>
);
}
}
export default translate(connect()(DialInSummary));

View File

@@ -0,0 +1,26 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import AlertDialog from '../../../../base/dialog/components/native/AlertDialog';
import { translate } from '../../../../base/i18n/functions';
/**
* Dialog to inform the user that we couldn't fetch the dial-in info page.
*/
class DialInSummaryErrorDialog extends Component<WithTranslation> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<AlertDialog
contentKey = 'info.dialInSummaryError' />
);
}
}
export default translate(connect()(DialInSummaryErrorDialog));

View File

@@ -0,0 +1,25 @@
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
export const INDICATOR_COLOR = BaseTheme.palette.ui07;
const WV_BACKGROUND = BaseTheme.palette.ui03;
export default {
backDrop: {
backgroundColor: WV_BACKGROUND,
flex: 1
},
indicatorWrapper: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui10,
height: '100%',
justifyContent: 'center'
},
webView: {
backgroundColor: WV_BACKGROUND,
flex: 1
}
};

View File

@@ -0,0 +1,78 @@
import { Theme } from '@mui/material';
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import { _formatConferenceIDPin } from '../../../_utils';
interface IProps extends WithTranslation {
/**
* The conference id.
*/
conferenceID?: string | number;
/**
* The conference name.
*/
conferenceName: string;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
container: {
marginTop: 32,
maxWidth: 310,
padding: '16px 12px',
background: theme.palette.ui02,
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
borderRadius: 6,
'& *': {
userSelect: 'text'
}
},
confNameLabel: {
...theme.typography.heading6,
marginBottom: 18,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
},
descriptionLabel: {
...theme.typography.bodyShortRegularLarge,
marginBottom: 18
},
separator: {
width: '100%',
height: 1,
background: theme.palette.ui04,
marginBottom: 18
},
pinLabel: {
...theme.typography.heading6
}
};
});
const ConferenceID: React.FC<IProps> = ({ conferenceID, t }) => {
const { classes: styles } = useStyles();
return (
<div className = { styles.container }>
<div className = { styles.descriptionLabel }>
{ t('info.dialANumber') }
</div>
<div className = { styles.separator } />
<div className = { styles.pinLabel }>
{ `${t('info.dialInConferenceID')} ${_formatConferenceIDPin(conferenceID ?? '')}` }
</div>
</div>
);
};
export default translate(ConferenceID);

View File

@@ -0,0 +1,312 @@
import { Theme } from '@mui/material';
import clsx from 'clsx';
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { withStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import { getDialInConferenceID, getDialInNumbers } from '../../../_utils';
import ConferenceID from './ConferenceID';
import NumbersList from './NumbersList';
/**
* The type of the React {@code Component} props of {@link DialInSummary}.
*/
interface IProps extends WithTranslation {
/**
* Additional CSS classnames to append to the root of the component.
*/
className: string;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Whether or not numbers should include links with the telephone protocol.
*/
clickableNumbers: boolean;
/**
* Whether to hide the error.
*/
hideError?: boolean;
/**
* The name of the conference to show a conferenceID for.
*/
room: string;
/**
* Whether the dial in summary container is scrollable.
*/
scrollable?: boolean;
/**
* Whether the room name should show as title.
*/
showTitle?: boolean;
/**
* The url where we were loaded.
*/
url: any;
}
/**
* The type of the React {@code Component} state of {@link DialInSummary}.
*/
type State = {
/**
* The numeric ID of the conference, used as a pin when dialing in.
*/
conferenceID: string | null;
/**
* An error message to display.
*/
error: string;
/**
* Whether or not the app is fetching data.
*/
loading: boolean;
/**
* The dial-in numbers to be displayed.
*/
numbers: Array<Object> | Object | null;
/**
* Whether or not dial-in is allowed.
*/
numbersEnabled: boolean | null;
};
const styles = (theme: Theme) => {
return {
hasNumbers: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column' as const,
background: '#1E1E1E',
color: theme.palette.text01
},
scrollable: {
height: '100dvh',
overflowY: 'scroll' as const
},
roomName: {
margin: '40px auto 8px',
...theme.typography.heading5
}
};
};
/**
* Displays a page listing numbers for dialing into a conference and pin to
* the a specific conference.
*
* @augments Component
*/
class DialInSummary extends Component<IProps, State> {
override state = {
conferenceID: null,
error: '',
loading: true,
numbers: null,
numbersEnabled: null
};
/**
* Initializes a new {@code DialInSummary} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onGetNumbersSuccess = this._onGetNumbersSuccess.bind(this);
this._onGetConferenceIDSuccess
= this._onGetConferenceIDSuccess.bind(this);
this._setErrorMessage = this._setErrorMessage.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const getNumbers = this._getNumbers()
.then(this._onGetNumbersSuccess)
.catch(this._setErrorMessage);
const getID = this._getConferenceID()
.then(this._onGetConferenceIDSuccess)
.catch(this._setErrorMessage);
Promise.all([ getNumbers, getID ])
.then(() => {
this.setState({ loading: false });
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
let className = '';
let contents;
const { conferenceID, error, loading, numbersEnabled } = this.state;
const { hideError, showTitle, room, clickableNumbers, scrollable, t } = this.props;
const classes = withStyles.getClasses(this.props);
if (loading) {
contents = '';
} else if (numbersEnabled === false) {
contents = t('info.dialInNotSupported');
} else if (error) {
if (!hideError) {
contents = error;
}
} else {
className = clsx(classes.hasNumbers, scrollable && classes.scrollable);
contents = [
conferenceID
? <>
{ showTitle && <div className = { classes.roomName }>{ room }</div> }
<ConferenceID
conferenceID = { conferenceID }
conferenceName = { room }
key = 'conferenceID' />
</> : null,
<NumbersList
clickableNumbers = { clickableNumbers }
conferenceID = { conferenceID }
key = 'numbers'
numbers = { this.state.numbers } />
];
}
return (
<div className = { className }>
{ contents }
</div>
);
}
/**
* Creates an AJAX request for the conference ID.
*
* @private
* @returns {Promise}
*/
_getConferenceID() {
const { room } = this.props;
const { dialInConfCodeUrl, hosts } = config;
const mucURL = hosts?.muc;
if (!dialInConfCodeUrl || !mucURL || !room) {
return Promise.resolve();
}
let url = this.props.url || {};
if (typeof url === 'string' || url instanceof String) {
// @ts-ignore
url = new URL(url);
}
return getDialInConferenceID(dialInConfCodeUrl, room, mucURL, url)
.catch(() => Promise.reject(this.props.t('info.genericError')));
}
/**
* Creates an AJAX request for dial-in numbers.
*
* @private
* @returns {Promise}
*/
_getNumbers() {
const { room } = this.props;
const { dialInNumbersUrl, hosts } = config;
const mucURL = hosts?.muc;
if (!dialInNumbersUrl) {
return Promise.reject(this.props.t('info.dialInNotSupported'));
}
return getDialInNumbers(dialInNumbersUrl, room, mucURL ?? '')
.catch(() => Promise.reject(this.props.t('info.genericError')));
}
/**
* Callback invoked when fetching the conference ID succeeds.
*
* @param {Object} response - The response from fetching the conference ID.
* @private
* @returns {void}
*/
_onGetConferenceIDSuccess(response = { conference: undefined,
id: undefined }) {
const { conference, id } = response;
if (!conference || !id) {
return;
}
this.setState({ conferenceID: id });
}
/**
* Callback invoked when fetching dial-in numbers succeeds. Sets the
* internal to show the numbers.
*
* @param {Array|Object} response - The response from fetching
* dial-in numbers.
* @param {Array|Object} response.numbers - The dial-in numbers.
* @param {boolean} response.numbersEnabled - Whether or not dial-in is
* enabled, old syntax that is deprecated.
* @private
* @returns {void}
*/
_onGetNumbersSuccess(
response: Array<Object> | { numbersEnabled?: boolean; }) {
this.setState({
numbersEnabled:
Boolean(Array.isArray(response)
? response.length > 0 : response.numbersEnabled),
numbers: response
});
}
/**
* Sets an error message to display on the page instead of content.
*
* @param {string} error - The error message to display.
* @private
* @returns {void}
*/
_setErrorMessage(error: string) {
this.setState({
error
});
}
}
export default translate(withStyles(DialInSummary, styles));

View File

@@ -0,0 +1,71 @@
import React, { ComponentType } from 'react';
import BaseApp from '../../../../base/app/components/BaseApp';
import { isMobileBrowser } from '../../../../base/environment/utils';
import GlobalStyles from '../../../../base/ui/components/GlobalStyles.web';
import JitsiThemeProvider from '../../../../base/ui/components/JitsiThemeProvider.web';
import { parseURLParams } from '../../../../base/util/parseURLParams';
import { DIAL_IN_INFO_PAGE_PATH_NAME } from '../../../constants';
import NoRoomError from '../../dial-in-info-page/NoRoomError.web';
import DialInSummary from './DialInSummary';
/**
* Wrapper application for prejoin.
*
* @augments BaseApp
*/
export default class DialInSummaryApp extends BaseApp<any> {
/**
* Navigates to {@link Prejoin} upon mount.
*
* @returns {void}
*/
override async componentDidMount() {
await super.componentDidMount();
// @ts-ignore
const { room } = parseURLParams(window.location, true, 'search');
const { href } = window.location;
const ix = href.indexOf(DIAL_IN_INFO_PAGE_PATH_NAME);
const url = (ix > 0 ? href.substring(0, ix) : href) + room;
super._navigate({
component: () => (<>
{room
? <DialInSummary
className = 'dial-in-page'
clickableNumbers = { isMobileBrowser() }
room = { decodeURIComponent(room) }
scrollable = { true }
showTitle = { true }
url = { url } />
: <NoRoomError className = 'dial-in-page' />}
</>)
});
}
/**
* Overrides the parent method to inject {@link AtlasKitThemeProvider} as
* the top most component.
*
* @override
*/
override _createMainElement(component: ComponentType<any>, props: Object) {
return (
<JitsiThemeProvider>
<GlobalStyles />
{super._createMainElement(component, props)}
</JitsiThemeProvider>
);
}
/**
* Renders the platform specific dialog container.
*
* @returns {React$Element}
*/
override _renderDialogContainer() {
return null;
}
}

View File

@@ -0,0 +1,210 @@
import countries from 'i18n-iso-countries';
import en from 'i18n-iso-countries/langs/en.json';
import React, { useCallback, useMemo } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconSip } from '../../../../base/icons/svg';
countries.registerLocale(en);
interface INormalizedNumber {
/**
* The country code.
*/
countryCode?: string;
/**
* The formatted number.
*/
formattedNumber: string;
/**
* Whether the number is toll-free.
*/
tollFree?: boolean;
}
interface INumbersMapping {
[countryName: string]: Array<INormalizedNumber>;
}
interface IProps extends WithTranslation {
/**
* Whether or not numbers should include links with the telephone protocol.
*/
clickableNumbers: boolean;
/**
* The conference ID for dialing in.
*/
conferenceID: number | null;
/**
* The phone numbers to display. Can be an array of number Objects or an
* object with countries as keys and an array of numbers as values.
*/
numbers: INumbersMapping | null;
}
const NumbersList: React.FC<IProps> = ({ t, conferenceID, clickableNumbers, numbers: numbersMapping }) => {
const renderFlag = useCallback((countryCode: string) => {
if (countryCode) {
return (
<td className = 'flag-cell'>
{countryCode === 'SIP' || countryCode === 'SIP_AUDIO_ONLY'
? <Icon src = { IconSip } />
: <i className = { `flag iti-flag ${countryCode}` } />
}
</td>);
}
return null;
}, []);
const renderNumberLink = useCallback((number: string) => {
if (clickableNumbers) {
// Url encode # to %23, Android phone was cutting the # after
// clicking it.
// Seems that using ',' and '%23' works on iOS and Android.
return (
<a
href = { `tel:${number},${conferenceID}%23` }
key = { number } >
{number}
</a>
);
}
return number;
}, [ conferenceID, clickableNumbers ]);
const renderNumbersList = useCallback((numbers: Array<INormalizedNumber>) => {
const numbersListItems = numbers.map(number =>
(<li
className = 'dial-in-number'
key = { number.formattedNumber }>
{renderNumberLink(number.formattedNumber)}
</li>));
return (
<ul className = 'numbers-list'>
{numbersListItems}
</ul>
);
}, []);
const renderNumbersTollFreeList = useCallback((numbers: Array<INormalizedNumber>) => {
const tollNumbersListItems = numbers.map(number =>
(<li
className = 'toll-free'
key = { number.formattedNumber }>
{number.tollFree ? t('info.dialInTollFree') : ''}
</li>));
return (
<ul className = 'toll-free-list'>
{tollNumbersListItems}
</ul>
);
}, []);
const renderNumbers = useMemo(() => {
let numbers: INumbersMapping;
if (!numbersMapping) {
return;
}
if (Array.isArray(numbersMapping)) {
numbers = numbersMapping.reduce(
(resultNumbers: any, number: any) => {
// The i18n-iso-countries package insists on upper case.
const countryCode = number.countryCode.toUpperCase();
let countryName;
if (countryCode === 'SIP') {
countryName = t('info.sip');
} else if (countryCode === 'SIP_AUDIO_ONLY') {
countryName = t('info.sipAudioOnly');
} else {
countryName = t(`countries:countries.${countryCode}`);
// Some countries have multiple names as US ['United States of America', 'USA']
// choose the first one if that is the case
if (!countryName) {
countryName = t(`countries:countries.${countryCode}.0`);
}
}
if (resultNumbers[countryName]) {
resultNumbers[countryName].push(number);
} else {
resultNumbers[countryName] = [ number ];
}
return resultNumbers;
}, {});
} else {
numbers = {};
for (const [ country, numbersArray ]
of Object.entries(numbersMapping.numbers)) {
if (Array.isArray(numbersArray)) {
/* eslint-disable arrow-body-style */
const formattedNumbers = numbersArray.map(number => ({
formattedNumber: number
}));
/* eslint-enable arrow-body-style */
numbers[country] = formattedNumbers;
}
}
}
const rows: [JSX.Element] = [] as unknown as [JSX.Element];
Object.keys(numbers).forEach((countryName: string) => {
const numbersArray: Array<INormalizedNumber> = numbers[countryName];
const countryCode = numbersArray[0].countryCode
|| countries.getAlpha2Code(countryName, 'en')?.toUpperCase()
|| countryName;
rows.push(
<>
<tr
key = { countryName }>
{renderFlag(countryCode)}
<td className = 'country' >{countryName}</td>
</tr>
<tr>
<td />
<td className = 'numbers-list-column'>
{renderNumbersList(numbersArray)}
</td>
<td className = 'toll-free-list-column' >
{renderNumbersTollFreeList(numbersArray)}
</td>
</tr>
</>
);
});
return rows;
}, [ numbersMapping ]);
return (
<table className = 'dial-in-numbers-list'>
<tbody className = 'dial-in-numbers-body'>
{renderNumbers}
</tbody>
</table>
);
};
export default translate(NumbersList);