Add source code to the repository

This commit is contained in:
Damian Cherubini 2021-06-25 18:50:43 -03:00
Родитель 05d4d9cf94
Коммит a75b88a9a1
107 изменённых файлов: 22838 добавлений и 0 удалений

41
.eslintrc.json Normal file
Просмотреть файл

@ -0,0 +1,41 @@
{
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {},
"settings": {
"react": {
"version": "detect"
}
},
"overrides": [
{
"files": [
"**/*.tsx"
],
"rules": {
"react/prop-types": "off"
}
}
]
}

7
.prettierrc Normal file
Просмотреть файл

@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
}

16411
package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

74
package.json Normal file
Просмотреть файл

@ -0,0 +1,74 @@
{
"name": "teams-tx",
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^2.11.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"antd": "^4.4.2",
"axios": "^0.19.2",
"connected-react-router": "^6.8.0",
"jwt-decode": "^3.1.2",
"moment": "^2.27.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.0",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"uuid": "^8.3.2"
},
"scripts": {
"lint": "eslint .",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest --coverage",
"test:watch": "jest --watch --coverage",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.1.2",
"@types/enzyme": "^3.10.5",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/jest": "^26.0.0",
"@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^5.1.5",
"@types/redux-mock-store": "^1.0.2",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^3.2.0",
"@typescript-eslint/parser": "^3.2.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.5.0",
"eslint-plugin-react": "^7.20.0",
"jest": "^24.9.0",
"jest-sonar-reporter": "^2.0.0",
"redux-mock-store": "^1.5.4",
"ts-jest": "^26.1.0",
"typescript": "^3.9.5"
},
"jest": {
"testResultsProcessor": "jest-sonar-reporter"
}
}

18
public/config.json Normal file
Просмотреть файл

@ -0,0 +1,18 @@
{
"buildNumber": "0.0.0",
"apiBaseUrl": "https://localhost:8442/api",
"releaseDummyVariable": "empty",
"msalConfig": {
"spaClientId": "",
"apiClientId": "",
"groupId": "",
"authority": "",
"redirectUrl": ""
},
"featureFlags": {
"DISABLE_AUTHENTICATION": {
"description": "Disable authentication flow when true",
"isActive": false
}
}
}

Двоичные данные
public/favicon.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.0 KiB

40
public/index.html Normal file
Просмотреть файл

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Teams TX</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

15
public/manifest.json Normal file
Просмотреть файл

@ -0,0 +1,15 @@
{
"short_name": "Broadcast Protocols",
"name": "Broadcast Protocols for Teams",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

9
public/routes.json Normal file
Просмотреть файл

@ -0,0 +1,9 @@
{
"routes": [
{
"route": "/*",
"serve": "/index.html",
"statusCode": 200
}
]
}

60
src/App.tsx Normal file
Просмотреть файл

@ -0,0 +1,60 @@
import { Spin } from 'antd';
import React, { Fragment, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Route, Switch } from 'react-router-dom';
import IAppState from './services/store/IAppState';
import { setAuthenticationDisabled } from './stores/auth/actions';
import { initilizeAuthentication } from './stores/auth/asyncActions';
import { FEATUREFLAG_DISABLE_AUTHENTICATION } from './stores/config/constants';
import CallDetails from './views/call-details/CallDetails';
import Footer from './views/components/Footer';
import Header from './views/components/Header';
import PrivateRoute from './views/components/PrivateRoute';
import Home from './views/home/Home';
import JoinCall from './views/join-call/JoinCall';
import LoginPage from './views/login/LoginPage';
import BotServiceStatus from './views/service/BotServiceStatus';
import Unauthorized from './views/unauthorized/Unauthorized';
const App: React.FC = () => {
const dispatch = useDispatch();
const { initialized: authInitialized } = useSelector((state: IAppState) => state.auth);
const { app: appConfig, initialized: configInitialized } = useSelector((state: IAppState) => state.config);
const disableAuthFlag = appConfig?.featureFlags && appConfig.featureFlags[FEATUREFLAG_DISABLE_AUTHENTICATION];
useEffect(() => {
if (appConfig) {
disableAuthFlag?.isActive
? dispatch(setAuthenticationDisabled())
: dispatch(initilizeAuthentication(appConfig.msalConfig));
}
}, [configInitialized]);
if (!authInitialized) {
return (
<div id="app">
<Spin tip="Loading..."></Spin>
</div>
);
} else {
return (
<div id="app">
<div id="main">
<Switch>
<Route exact path="/login" component={LoginPage} />
<Route exact path="/login/unauthorized" component={Unauthorized} />
<Fragment>
<Header />
<PrivateRoute exact path="/" component={Home} />
<PrivateRoute path="/call/details/:id" component={CallDetails} />
<PrivateRoute exact path="/call/join" component={JoinCall} />
<PrivateRoute exact path="/botservice" component={BotServiceStatus} />
</Fragment>
</Switch>
</div>
<Footer />
</div>
);
}
};
export default App;

Двоичные данные
src/images/logo.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 6.7 KiB

54
src/index.css Normal file
Просмотреть файл

@ -0,0 +1,54 @@
body {
padding: 100px 0 0px 0;
background-color: #f3f2f1;
}
#root,
#HeaderInner {
min-width: 920px;
}
#main {
margin: 0 6%;
padding-bottom: 40px;
}
.PageBody {
background-color: #fff;
padding: 15px;
margin-bottom: 20px;
min-height: 180px;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
}
/* footer */
body,
#root,
#app {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
}
#Footer {
margin-top: auto;
border-top: 1px solid #ddd;
padding: 8px;
text-align: center;
font-size: 0.8em;
}
/* misc */
.break {
display: block;
clear: both;
}
h2 {
margin: 20px 0;
}

29
src/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider as ReduxProvider } from "react-redux";
import { ConnectedRouter } from "connected-react-router";
import configureStore, { DispatchExts, history } from "./services/store";
import 'antd/dist/antd.css';
import './index.css';
import App from './App';
import { loadConfig } from './stores/config/asyncActions';
import { pollCurrentCallAsync } from './stores/calls/asyncActions';
const store = configureStore();
const storeDispatch = store.dispatch as DispatchExts;
// triger config loading
storeDispatch(loadConfig())
// trigger automatic polling of selected call
storeDispatch(pollCurrentCallAsync());
ReactDOM.render(<>
<ReduxProvider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</ReduxProvider>
</>, document.getElementById('root'));

Просмотреть файл

@ -0,0 +1,17 @@
import { Middleware } from 'redux';
import IAppState from '../services/store/IAppState';
import { notification } from 'antd';
const errorToastMiddleware: Middleware<{}, IAppState> = (store) => (next) => (action) => {
if (action.error) {
const errorAction = action;
errorAction.payload.status === 401
? notification.error({ description: 'Unauthorized: Please, Sing in again.', message: `Error` })
: notification.error({ description: errorAction.payload.message, message: `Error` });
}
next(action);
};
export default errorToastMiddleware;

17
src/models/auth/types.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
export enum AuthStatus {
Unauthenticated,
Unauthorized,
Authenticating,
Authenticated,
}
export interface UserProfile {
id: string;
username: string;
role: UserRoles
}
export enum UserRoles {
Producer = "Producer",
Attendee = "Attendee",
}

199
src/models/calls/types.ts Normal file
Просмотреть файл

@ -0,0 +1,199 @@
export interface Call {
id: string;
joinUrl: string;
displayName: string; // Call/Room name
state: CallState; // Initializing, Established, Terminating, Terminated
errorMessage: string | null; // Error message (if any)
createdAt: Date;
meetingType: CallType; // Unknown, Normal, Event
botFqdn: string | null;
botIp: string | null;
connectionPool: ConnectionPool;
defaultProtocol: StreamProtocol;
defaultPassphrase: string;
defaultLatency: number;
streams: Stream[];
injectionStream: InjectionStream | null;
privateContext: PrivateContext | null;
}
export enum StreamProtocol {
SRT = 0,
RTMP = 1,
}
export enum CallState {
Establishing,
Established,
Terminating,
Terminated,
}
export enum CallType {
Default,
Event,
}
export interface ConnectionPool {
used: number;
available: number;
}
export interface Stream {
id: string; //internal id
callId: string;
participantGraphId: string; //id form teams meeting
displayName: string; // User name or Stream name
photoUrl: string | null;
type: StreamType; // VbSS, DominantSpeaker, Participant
state: StreamState; // Disconnected, Initializing, Established, Disconnecting, Error
isHealthy: boolean;
healthMessage: string;
isSharingScreen: boolean;
isSharingVideo: boolean;
isSharingAudio: boolean;
audioMuted: boolean;
details: StreamDetails | null;
}
export interface StartStreamRequest {
participantId?: string;
participantGraphId?: string;
type: StreamType;
callId: string;
protocol: StreamProtocol;
config: StreamConfiguration;
}
export interface StopStreamRequest {
callId: string;
type: StreamType;
participantId?: string;
participantGraphId?: string;
participantName?: string;
}
export interface NewInjectionStream {
callId: string;
streamUrl?: string;
streamKey?: string;
protocol?: StreamProtocol;
mode?: StreamMode;
latency?: number;
enableSsl?: boolean;
}
export interface StopInjectionRequest {
callId: string;
streamId: string;
}
export type StreamConfiguration = {
streamUrl: string;
streamKey: string;
unmixedAudio: boolean;
audioFormat: number;
timeOverlay: boolean;
};
export interface StreamSrtConfiguration extends StreamConfiguration {
mode: StreamMode;
latency: number;
}
export interface NewCall {
callUrl: string;
status: CallState;
errorMessage?: string;
}
export interface CallDefaults {
protocol: StreamProtocol;
latency: number;
passphrase: string;
}
export enum StreamState {
Disconnected,
Starting,
Started,
Stopping,
StartingError,
StoppingError,
Error,
}
export const ActiveStatuses = [StreamState.Started, StreamState.Stopping];
export const InactiveStatuses = [StreamState.Disconnected, StreamState.Starting];
export enum StreamType {
VbSS,
PrimarySpeaker,
Participant,
}
export const SpecialStreamTypes = [StreamType.VbSS, StreamType.PrimarySpeaker];
export enum StreamMode {
Caller = 1,
Listener = 2,
}
export interface NewStream {
callId: string;
participantId?: string;
participantName?: string;
streamType: StreamType;
mode?: StreamMode;
advancedSettings: {
url?: string;
latency?: number;
key?: string;
unmixedAudio: boolean;
};
}
export interface StreamDetails {
streamUrl: string;
passphrase: string;
latency: number;
previewUrl: string;
audioDemuxed: boolean;
}
export interface InjectionStream {
id: string;
callId: string;
injectionUrl?: string;
protocol: StreamProtocol;
streamMode: StreamMode;
state?: StreamState;
startingAt: string;
startedAt: string;
endingAt: string;
endedAt: string;
latency: number;
passphrase: string;
audioMuted: boolean;
}
export interface NewStreamDrawerOpenParameters {
callId: string;
streamType: StreamType;
participantId?: string;
participantName?: string;
}
export interface NewInjectionStreamDrawerOpenParameters {
callId: string;
}
export interface PrivateContext {
streamKey: string;
}
export interface CallStreamKey {
callId: string;
streamKey: string;
}

Просмотреть файл

@ -0,0 +1,12 @@
import { ApiError } from "./types";
export const fillApiErrorWithDefaults = (error: Partial<ApiError>, requestUrl: string): ApiError => {
const errorResponse = new ApiError();
errorResponse.status = error.status || 0;
errorResponse.message = error.message || 'An error ocurred'; //TODO: Change this message
errorResponse.errors = error.errors!.length ? error.errors! : ['An error ocurred'];
errorResponse.url = error.url || requestUrl;
return errorResponse;
};

30
src/models/error/types.ts Normal file
Просмотреть файл

@ -0,0 +1,30 @@
import { v4 as uuidv4 } from 'uuid';
export type ApplicationError = ApiError | DefaultError;
export interface ErrorDefaults {
id: string;
message: string;
}
export class DefaultError implements ErrorDefaults {
id: string = uuidv4();
message: string = '';
raw: any = null;
constructor(message: string, raw?: any) {
this.message = message;
if (raw) {
this.raw = raw;
}
}
}
export class ApiError implements ErrorDefaults {
id: string = uuidv4();
message: string = '';
status: number = 0;
errors: string[] = [];
url: string = '';
raw: any = null;
}

Просмотреть файл

@ -0,0 +1,72 @@
export enum BotServiceInfrastructureState {
Running = "PowerState/running",
Deallocating = "PowerState/deallocating",
Deallocated = "PowerState/deallocated",
Starting = "PowerState/starting",
Stopped = "PowerState/stopped",
Stopping = "PowerState/stopping",
Unknown = "PowerState/unknown",
}
export interface BotService {
id: string;
name: string;
callId: string;
serviceState: BotServiceStates;
infrastructure: Infrastructure;
}
export interface Infrastructure {
virtualMachineName: string;
resourceGroup: string;
subscriptionId: string;
powerState: BotServiceInfrastructureState;
provisioningDetails: ProvisioningDetails;
}
export interface ProvisioningDetails {
state: ProvisioningState;
messsage: string;
}
export interface ProvisioningState {
id: ProvisioningStateValues;
name: string;
}
export enum ProvisioningStateValues {
Provisioning = 0,
Provisioned = 1,
Deprovisioning = 2,
Deprovisioned = 3,
Error = 4,
Unknown = 5
}
export enum TeamsColors {
Red = "#D74654",
Purple = "#6264A7",
Black = "#11100F",
Green = "#7FBA00",
Grey = "#BEBBB8",
MiddleGrey = "#3B3A39",
DarkGrey = "#201F1E",
White = "white",
}
export enum TeamsMargins {
micro = "4px",
small = "8px",
medium = "20px",
large = "40px",
}
export enum BotServiceStates
{
Starting = 0,
Available = 1,
Unavailable = 2,
Stopping = 3,
Stopped = 4,
Unknown = 5
}

Просмотреть файл

@ -0,0 +1,5 @@
export enum ToastStatusEnum {
Error = 'error',
Warning = 'warning',
Success = 'success',
}

1
src/react-app-env.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
/// <reference types="react-scripts" />

1
src/sample.md Normal file
Просмотреть файл

@ -0,0 +1 @@
#WIP

259
src/services/api/index.ts Normal file
Просмотреть файл

@ -0,0 +1,259 @@
import Axios, { Method, AxiosRequestConfig } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { fillApiErrorWithDefaults } from '../../models/error/helpers';
import { ApiError } from '../../models/error/types';
import AuthService from '../../services/auth';
import { FEATUREFLAG_DISABLE_AUTHENTICATION } from '../../stores/config/constants';
import { getConfig } from '../../stores/config/loader';
export enum RequestMethod {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Delete = 'DELETE',
Options = 'OPTIONS',
Head = 'HEAD',
Patch = 'PATCH',
}
export interface RequestParameters {
url: string;
isSecured: boolean;
shouldOverrideBaseUrl?: boolean;
payload?: unknown;
method?: RequestMethod;
config?: AxiosRequestConfig;
}
export class ApiClient {
public static async post<T>({
url,
isSecured,
shouldOverrideBaseUrl: shouldOverrideUrl,
payload,
config,
}: RequestParameters): Promise<RequestResponse<T>> {
return baseRequest<T>({ url, isSecured, shouldOverrideBaseUrl: shouldOverrideUrl, payload, method: RequestMethod.Post, config });
}
public static async put<T>({
url,
isSecured,
shouldOverrideBaseUrl: shouldOverrideUrl,
payload,
config,
}: RequestParameters): Promise<RequestResponse<T>> {
return baseRequest<T>({ url, isSecured, shouldOverrideBaseUrl: shouldOverrideUrl, payload, method: RequestMethod.Put, config });
}
public static async get<T>({
url,
isSecured,
shouldOverrideBaseUrl: shouldOverrideUrl,
payload,
config,
}: RequestParameters): Promise<RequestResponse<T>> {
return baseRequest<T>({ url, isSecured, shouldOverrideBaseUrl: shouldOverrideUrl, payload, method: RequestMethod.Get, config });
}
public static async delete<T>({
url,
isSecured,
shouldOverrideBaseUrl: shouldOverrideUrl,
payload,
config,
}: RequestParameters): Promise<RequestResponse<T>> {
return baseRequest<T>({ url, isSecured, shouldOverrideBaseUrl: shouldOverrideUrl, payload, method: RequestMethod.Delete, config });
}
}
const baseRequest = async <T>({
url,
isSecured,
shouldOverrideBaseUrl: shouldOverrideUrl,
payload,
method,
config,
}: RequestParameters): Promise<RequestResponse<T>> => {
try {
const {
apiBaseUrl,
msalConfig: { apiClientId },
featureFlags,
} = await getConfig();
const disableAuthFlag = featureFlags && featureFlags[FEATUREFLAG_DISABLE_AUTHENTICATION];
let headers: any;
if (isSecured && !disableAuthFlag?.isActive) {
const token = await refreshAccessToken(apiClientId);
headers = {
Authorization: `Bearer ${token}`,
};
}
const requestConfig: AxiosRequestConfig = {
url: shouldOverrideUrl ? url : `${apiBaseUrl}${url}`,
method: method as Method,
data: payload,
headers: {
'x-client': 'Management Portal',
...headers,
},
...config,
};
const [response] = await Promise.all([Axios(requestConfig), delay()]);
const { status, data, request } = response;
if (data.success === false) {
const errorResponse = fillApiErrorWithDefaults(
{
status,
message: data.errors.join(' - '),
errors: data.errors,
url: request ? request.responseURL : url,
raw: response,
},
url
);
return errorResponse;
}
return data as T;
} catch (error) {
//The request was made and the server responded with an status code different of 2xx
console.log({
error: JSON.stringify(error),
});
if (error.response) {
const { value } = error.response.data;
//TODO: Modify how we parse de error. Acording to our exception responses, we should look the property value
const errors: string[] =
value && Object.prototype.hasOwnProperty.call(value, 'errors')
? [value?.title, value?.detail, concatErrorMessages(value?.errors)]
: [value?.title, value?.detail];
const serverError = fillApiErrorWithDefaults(
{
status: error.response.status,
message: errors.filter(Boolean).join(' - '),
errors,
url: error.request.responseURL,
raw: error.response,
},
url
);
return serverError;
}
//The request was made but no response was received
if (error.request) {
const { status, statusText, responseURL } = error.request;
const unknownError = fillApiErrorWithDefaults(
{
status,
message: `${error.message} ${statusText}`,
errors: [statusText],
url: responseURL,
raw: error.request,
},
url
);
return unknownError;
}
//Something happened during the setup
const defaultError = fillApiErrorWithDefaults(
{
status: 0,
message: error.message,
errors: [error.message],
url: url,
raw: error,
},
url
);
return defaultError;
}
};
export const refreshAccessToken = async (apiClientId: string): Promise<string | undefined> => {
const accounts = AuthService.getAccounts();
if (accounts && accounts.length > 0) {
try {
const authResult = await AuthService.requestSilentToken(accounts[0], apiClientId);
return authResult.accessToken;
} catch (error) {
console.error(error);
}
}
};
const concatErrorMessages = (errors: Record<string, unknown>): string[] => {
const errorsArray: string[] = [];
Object.values(errors).forEach((element) => {
Array.isArray(element) ? errorsArray.push(element.join(' - ')) : errorsArray.push(JSON.stringify(element));
});
return errorsArray;
};
const delay = (duration: number = 250): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, duration));
};
export type RequestResponse<T> = T | ApiError;
export interface Resource<T> {
id: string;
resource: T;
}
//TODO: Remove after migrating async actions
export const api = async <T>(url: string, method: Method, json?: unknown): Promise<T> => {
try {
const {
apiBaseUrl,
msalConfig: { apiClientId },
featureFlags,
} = await getConfig();
const disableAuthFlag = featureFlags && featureFlags[FEATUREFLAG_DISABLE_AUTHENTICATION];
const token = !disableAuthFlag?.isActive ? await refreshAccessToken(apiClientId) : '';
const headersConfig = !disableAuthFlag?.isActive ? { Authorization: `Bearer ${token}` } : {};
// Request Auth
const request: AxiosRequestConfig = {
url: `${apiBaseUrl}${url}`,
method: method,
data: json,
headers: {
...headersConfig,
'X-Client': 'Management Portal',
},
};
// TODO: Handle proper return codes
const response = await Axios(request);
return response.data as T;
} catch (err) {
// Handle HTTP errors
const errorMessage = !err.response?.data?.error_description ? err.toString() : err.response.data.error_description;
throw new Error(errorMessage);
}
};

Просмотреть файл

@ -0,0 +1,85 @@
import {
AccountInfo,
AuthenticationResult,
Configuration,
EndSessionRequest,
PublicClientApplication,
SilentRequest,
} from '@azure/msal-browser';
import jwtDecode from 'jwt-decode';
import { UserProfile, UserRoles } from '../../models/auth/types';
import { MsalConfig } from '../../stores/config/types';
interface DecodedToken {
groups: string[];
}
export default class AuthService {
private static msalClient: PublicClientApplication;
private static appConfig: MsalConfig;
public static configure(config: MsalConfig): void {
AuthService.appConfig = config;
const msalConfig: Configuration = {
auth: {
authority: config?.authority,
clientId: config?.spaClientId || '',
redirectUri: config?.redirectUrl,
},
cache: {
cacheLocation: 'localStorage',
storeAuthStateInCookie: false,
},
};
AuthService.msalClient = new PublicClientApplication(msalConfig);
}
public static async signIn(apiClientId: string | undefined): Promise<AuthenticationResult> {
const loginRequest = {
scopes: ['openid', 'profile', 'offline_access', `api://${apiClientId}/.default`],
};
return await AuthService.msalClient.loginPopup(loginRequest);
}
public static async signOut(username: string): Promise<void> {
const request: EndSessionRequest = {
account: AuthService.msalClient.getAccountByUsername(username) || undefined,
};
await AuthService.msalClient.logout(request);
}
public static getAccounts(): AccountInfo[] {
return AuthService.msalClient.getAllAccounts();
}
public static async requestSilentToken(account: AccountInfo, apiClientId: string): Promise<AuthenticationResult> {
const request: SilentRequest = {
account,
scopes: ['openid', 'profile', 'offline_access', `api://${apiClientId}/.default`],
};
return await AuthService.msalClient.acquireTokenSilent(request);
}
public static getUserProfile(authResult: AuthenticationResult): UserProfile {
const userRole = AuthService.getUserRole(authResult.accessToken);
const userProfile: UserProfile = {
id: authResult.account?.localAccountId || '',
username: authResult.account?.username || '',
role: userRole,
};
return userProfile;
}
private static getUserRole(jwtToken: string): UserRoles {
const groupId = AuthService.appConfig.groupId;
const decodedToken = jwtDecode(jwtToken) as DecodedToken;
// If users are in the RBAC group then they have the Producer/Broadcast role
const role = decodedToken.groups && decodedToken.groups.includes(groupId) ? UserRoles.Producer: UserRoles.Attendee;
return role;
}
}

13
src/services/helpers.ts Normal file
Просмотреть файл

@ -0,0 +1,13 @@
export const extractLinks = (rawHTML: string): string[] => {
const doc = document.createElement('html');
doc.innerHTML = rawHTML;
const links = doc.getElementsByTagName('a')
const urls = [];
for (let i = 0; i < links.length; i++) {
urls.push(links[i].getAttribute('href'));
}
return urls.filter(Boolean) as string[];
};

Просмотреть файл

@ -0,0 +1,19 @@
import { RouterState } from 'connected-react-router'
import { AuthState } from '../../stores/auth/reducer';
import { ICallsState } from '../../stores/calls/reducer';
import { ConfigState } from '../../stores/config/reducer';
import { ErrorState } from '../../stores/error/reducer';
import { RequestingState } from '../../stores/requesting/reducer';
import { BotServiceAppState } from '../../stores/service/reducer';
import { IToastState } from '../../stores/toast/reducer';
export default interface IAppState {
router: RouterState,
config: ConfigState,
auth: AuthState;
calls: ICallsState;
errors: ErrorState;
requesting: RequestingState;
toast: IToastState;
botServiceStatus: BotServiceAppState;
}

Просмотреть файл

@ -0,0 +1,46 @@
import { applyMiddleware, combineReducers, createStore, AnyAction, CombinedState, Store } from 'redux';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { History } from 'history';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunkMiddleware, { ThunkMiddleware, ThunkDispatch } from 'redux-thunk';
import { createBrowserHistory } from 'history';
import IAppState from './IAppState';
import { callsReducer } from '../../stores/calls/reducer';
import { configReducer } from '../../stores/config/reducer';
import { authReducer } from '../../stores/auth/reducer';
import errorReducer from '../../stores/error/reducer';
import requestingReducer from '../../stores/requesting/reducer';
import { toastReducer } from '../../stores/toast/reducer';
import errorToastMiddleware from '../../middlewares/errorToastMiddleware';
import { serviceReducer } from '../../stores/service/reducer';
const createRootReducer = (history: History) =>
combineReducers<IAppState>({
router: connectRouter(history),
config: configReducer,
auth: authReducer,
calls: callsReducer,
errors: errorReducer,
toast: toastReducer,
requesting: requestingReducer,
botServiceStatus: serviceReducer,
});
const configureStore = (): Store<CombinedState<IAppState>, AnyAction> =>
createStore(
createRootReducer(history),
composeWithDevTools(
applyMiddleware(
routerMiddleware(history),
errorToastMiddleware,
thunkMiddleware as ThunkMiddleware<IAppState, AnyAction>
)
)
);
export const history = createBrowserHistory();
export default configureStore;
export type DispatchExts = ThunkDispatch<IAppState, undefined, AnyAction>;

Просмотреть файл

@ -0,0 +1,23 @@
import { AnyAction } from "redux";
import { MockStore } from "redux-mock-store";
// Test Helpers
export function findAction(store: MockStore, type: string): AnyAction {
return store.getActions().find((action) => action.type === type);
}
export function getAction(store: MockStore, type: string): Promise<AnyAction> {
const action = findAction(store, type);
if (action) {
return Promise.resolve(action);
}
return new Promise((resolve) => {
store.subscribe(() => {
const eventualAction = findAction(store, type);
if (eventualAction) {
resolve(eventualAction);
}
});
});
}

3
src/setupEnzyme.ts Normal file
Просмотреть файл

@ -0,0 +1,3 @@
import * as Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() })

Просмотреть файл

@ -0,0 +1,79 @@
import { UserProfile } from '../../models/auth/types';
import { DefaultError } from '../../models/error/types';
import { AuthStatus } from '../../models/auth/types';
import BaseAction from '../base/BaseAction';
export type AuthActions =
| AuthStateInitialized
| UserAuthenticating
| UserAuthenticated
| UserUnauthorized
| UserAuthenticationError;
export const AUTH_STATE_INITIALIZED = 'AUTH_STATE_INITIALIZED';
export interface AuthStateInitialized extends BaseAction<{ initialized: boolean }> {}
export const authStateInitialized = (): AuthStateInitialized => ({
type: AUTH_STATE_INITIALIZED,
payload: {
initialized: true,
},
});
export const USER_AUTHENTICATING = 'USER_AUTHENTICATING';
export interface UserAuthenticating extends BaseAction<{ authStatus: AuthStatus }> {}
export const userAuthenticating = (): UserAuthenticating => ({
type: USER_AUTHENTICATING,
payload: {
authStatus: AuthStatus.Authenticating,
},
});
export const USER_AUTHENTICATED = 'USER_AUTHENTICATED';
export interface UserAuthenticated extends BaseAction<{ userProfile: UserProfile; authStatus: AuthStatus }> {}
export const userAuthenticated = (userProfile: UserProfile): UserAuthenticated => ({
type: USER_AUTHENTICATED,
payload: {
userProfile,
authStatus: AuthStatus.Authenticated,
},
});
export const USER_UNAUTHENTICATED = 'USER_UNAUTHENTICATED';
export interface UserUnauthenticated extends BaseAction<{ authStatus: AuthStatus }> {}
export const userUnauthenticated = (): UserUnauthenticated => ({
type: USER_UNAUTHENTICATED,
payload: {
authStatus: AuthStatus.Unauthenticated,
},
});
export const USER_UNAUTHORIZED = 'USER_UNAUTHORIZED';
export interface UserUnauthorized extends BaseAction<{ userProfile: UserProfile; authStatus: AuthStatus }> {}
export const userUnauthorized = (userProfile: UserProfile): UserUnauthorized => ({
type: USER_UNAUTHORIZED,
payload: {
userProfile,
authStatus: AuthStatus.Unauthorized,
},
});
export const USER_AUTHENTICATION_ERROR = 'USER_AUTHENTICATION_ERROR';
export interface UserAuthenticationError extends BaseAction<DefaultError> {}
export const userAuthenticationError = (message: string, rawError: any): UserAuthenticationError => ({
type: USER_AUTHENTICATION_ERROR,
payload: new DefaultError(message, rawError),
error: true,
});
export const SET_AUTHENTICATION_DISABLED = "SET_AUTHENTICATION_DISABLED";
export interface SetAuthenticationDisabled extends BaseAction<undefined> {};
export const setAuthenticationDisabled = (): SetAuthenticationDisabled => ({
type: SET_AUTHENTICATION_DISABLED,
})

Просмотреть файл

@ -0,0 +1,77 @@
import { push } from 'connected-react-router';
import { AnyAction } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { UserProfile, UserRoles } from '../../models/auth/types';
import AuthService from '../../services/auth';
import IAppState from '../../services/store/IAppState';
import { MsalConfig } from '../config/types';
import { authStateInitialized, userAuthenticated, userAuthenticating, userAuthenticationError, userUnauthenticated, userUnauthorized } from './actions';
const UNAUTHORIZE_ENDPOINT = "/login/unauthorized";
export const initilizeAuthentication =
(config: MsalConfig): ThunkAction<void, IAppState, undefined, AnyAction> =>
async (dispatch, getState) => {
AuthService.configure(config);
// Check for accounts in browser
const accounts = AuthService.getAccounts();
if (accounts && accounts.length > 0) {
try {
dispatch(userAuthenticating());
const authResult = await AuthService.requestSilentToken(accounts[0], config.apiClientId);
const userProfile = AuthService.getUserProfile(authResult);
if (userIsProducer(userProfile)) {
dispatch(userAuthenticated(userProfile));
} else {
dispatch(userUnauthorized(userProfile));
dispatch(push(UNAUTHORIZE_ENDPOINT));
}
} catch (error) {
dispatch(userAuthenticationError("Error has ocurred while trying to initilize authentication", error));
}
}
// Dispatch MSAL config loaded
dispatch(authStateInitialized());
};
export const signIn = (): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch, getState) => {
const msalConfig = getState().config.app?.msalConfig;
dispatch(userAuthenticating());
try {
const authResult = await AuthService.signIn(msalConfig?.apiClientId);
const userProfile = AuthService.getUserProfile(authResult);
if (userIsProducer(userProfile)) {
dispatch(userAuthenticated(userProfile));
} else {
dispatch(userUnauthorized(userProfile));
dispatch(push(UNAUTHORIZE_ENDPOINT));
}
} catch (error) {
console.error(error);
dispatch(userAuthenticationError("Error has ocurred while trying to sign in", error));
}
}
export const signOut = (
username: string
): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch) => {
try {
await AuthService.signOut(username);
// Dispatch sign-out action
dispatch(userUnauthenticated());
} catch (error) {
console.error(error);
// Dispatch error action
dispatch(userAuthenticationError("Error has ocurred while trying to sign out", error));
}
};
const userIsProducer = (userProfile: UserProfile): boolean => {
return userProfile.role === UserRoles.Producer;
}

Просмотреть файл

@ -0,0 +1,62 @@
import { AuthStatus, UserProfile, UserRoles } from '../../models/auth/types';
import * as AuthActions from './actions';
import baseReducer from '../base/BaseReducer';
export interface AuthState {
status: AuthStatus;
userProfile: UserProfile | null;
initialized: boolean;
}
export const INITIAL_STATE: AuthState = {
status: AuthStatus.Unauthenticated,
userProfile: null,
initialized: false,
};
export const authReducer = baseReducer(INITIAL_STATE, {
[AuthActions.USER_AUTHENTICATED](state: AuthState, action: AuthActions.UserAuthenticated): AuthState {
return {
...state,
status: action.payload!.authStatus,
userProfile: action.payload!.userProfile,
}
},
[AuthActions.USER_UNAUTHENTICATED](state: AuthState, action: AuthActions.UserUnauthenticated): AuthState {
return {
...state,
status: action.payload!.authStatus,
}
},
[AuthActions.USER_AUTHENTICATING](state: AuthState, action: AuthActions.UserAuthenticating): AuthState {
return {
...state,
status: action.payload!.authStatus,
}
},
[AuthActions.USER_UNAUTHORIZED](state: AuthState, action: AuthActions.UserUnauthorized): AuthState {
return {
...state,
status: action.payload!.authStatus,
userProfile: action.payload!.userProfile,
}
},
[AuthActions.AUTH_STATE_INITIALIZED](state: AuthState, action: AuthActions.AuthStateInitialized): AuthState {
return {
...state,
initialized: action.payload!.initialized,
}
},
[AuthActions.SET_AUTHENTICATION_DISABLED](state: AuthState, action: AuthActions.SetAuthenticationDisabled): AuthState {
return {
...state,
status: AuthStatus.Authenticated,
userProfile: {
id: '',
username: 'Local User',
role: UserRoles.Producer,
},
initialized: true,
}
},
})

Просмотреть файл

@ -0,0 +1,23 @@
import { Action } from 'redux';
import { RequestResponse } from '../../services/api';
export interface RequestFinishedActionParameters<T> {
payload: RequestResponse<T>;
meta?: any;
}
export default interface BaseAction<T> extends Action<string> {
type: string;
payload?: T;
error?: boolean;
meta?: any;
}
export const createBaseAction = <T = undefined>({
type,
payload,
error = false,
meta = null,
}: BaseAction<T>): BaseAction<T> => {
return { type, payload, error, meta };
};

Просмотреть файл

@ -0,0 +1,23 @@
import { AnyAction, Reducer } from 'redux';
import BaseAction from './BaseAction';
type ReducerMethod<T> = (state: T, action: BaseAction<any>) => T;
type ReducerMethods<T> = { [actionType: string]: ReducerMethod<T> };
/*
The API related reducers have to implement this baseReducer. If an action of type
REQUEST_SOMETHING_FINISHED is flagged with error, it doesn't have to be processed.
*/
export default function baseReducer<T = any>(initialState: T, methods: ReducerMethods<T>): Reducer<T> {
return (state: T = initialState, action: BaseAction<any>): T => {
const method: ReducerMethod<T> | undefined = methods[action.type];
// if the action doesn't have a method or it has been flagged as error
// we return the current state and do not mutate the state
if (!method || action.error) {
return state;
}
return method(state, action);
};
}

Просмотреть файл

@ -0,0 +1,23 @@
import { Call } from "../../../models/calls/types";
import { ApiError } from "../../../models/error/types";
import { RequestResponse, Resource } from "../../../services/api";
import BaseAction, { RequestFinishedActionParameters } from "../../base/BaseAction";
export const REQUEST_DISCONNECT_CALL = 'REQUEST_DISCONNECT_CALL';
export const REQUEST_DISCONNECT_CALL_FINISHED = 'REQUEST_DISCONNECT_CALL_FINISHED';
export interface RequestDisconnectCall extends BaseAction<undefined> {}
export interface RequestDisconnectCallFinished extends BaseAction<RequestResponse<Resource<Call>>> {}
export const requestDisconnectCall = (): RequestDisconnectCall => ({
type: REQUEST_DISCONNECT_CALL,
});
export const requestDisconnectCallFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<Call>>): RequestDisconnectCallFinished => ({
type: REQUEST_DISCONNECT_CALL_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { Call } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_ACTIVE_CALLS = 'REQUEST_ACTIVE_CALLS';
export const REQUEST_ACTIVE_CALLS_FINISHED = 'REQUEST_ACTIVE_CALLS_FINISHED';
export interface RequestActiveCalls extends BaseAction<undefined> {}
export interface RequestActiveCallsFinished extends BaseAction<RequestResponse<Call[]>> {}
export const requestActiveCalls = (): RequestActiveCalls => ({
type: REQUEST_ACTIVE_CALLS,
});
export const requestActiveCallsFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Call[]>): RequestActiveCallsFinished => ({
type: REQUEST_ACTIVE_CALLS_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,48 @@
import { Call } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export enum RequestCallType {
NewCall = 'NewCall',
ExistingCall = 'ExistingCall',
}
export const REQUEST_CALL = 'REQUEST_CALL';
export const REQUEST_CALL_FINISHED = 'REQUEST_CALL_FINISHED';
export interface RequestCall extends BaseAction<undefined> {}
export interface RequestCallFinished extends BaseAction<RequestResponse<Call>> {}
export const requestCall = (): BaseAction<undefined> => ({
type: REQUEST_CALL,
});
export const requestCallFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Call>): BaseAction<RequestResponse<Call>> => ({
type: REQUEST_CALL_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});
export const REQUEST_POLLING_CALL = 'REQUEST_POLLING_CALL';
export const REQUEST_POLLING_CALL_FINISHED = 'REQUEST_POLLING_CALL_FINISHED';
export interface RequestPollingCall extends BaseAction<undefined> {}
export interface RequestPollingCallFinished extends BaseAction<RequestResponse<Call>> {}
export const requestPollingCall = (): BaseAction<undefined> => ({
type: REQUEST_POLLING_CALL,
});
export const requestPollingCallFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Call>): BaseAction<RequestResponse<Call>> => ({
type: REQUEST_POLLING_CALL_FINISHED,
payload: payload,
meta: meta,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,14 @@
export * from './disconnectCall';
export * from './getCall';
export * from './joinCall';
export * from './newStreamDrawer';
export * from './startStream';
export * from './stopStream';
export * from './getActiveCalls';
export * from './startInjectionStream';
export * from './stopInjectionStream';
export * from './muteBot';
export * from './unmuteBot';
export * from './newInjectionStreamDrawer';
export * from './updateDefaults';
export * from './refreshStreamKey';

Просмотреть файл

@ -0,0 +1,26 @@
import { Call } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_JOIN_CALL = 'REQUEST_JOIN_CALL';
export const REQUEST_JOIN_CALL_FINISHED = 'REQUEST_JOIN_CALL_FINISHED';
export interface RequestJoinCall extends BaseAction<{ callUrl: string }> {}
export interface RequestJoinCallFinished extends BaseAction<RequestResponse<Call>> {}
export const requestJoinCall = (callUrl: string): BaseAction<{ callUrl: string }> => ({
type: REQUEST_JOIN_CALL,
payload: {
callUrl,
},
});
export const requestJoinCallFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Call>): BaseAction<RequestResponse<Call>> => ({
type: REQUEST_JOIN_CALL_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { InjectionStream } from '../../../models/calls/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
import { ApiError } from '../../../models/error/types';
export const REQUEST_MUTE_BOT = 'REQUEST_MUTE_BOT';
export const REQUEST_MUTE_BOT_FINISHED = 'REQUEST_MUTE_BOT_FINISHED';
export interface RequestMuteBot extends BaseAction<undefined> {}
export interface RequestMuteBotFinished extends BaseAction<RequestResponse<InjectionStream>> {}
export const requestMuteBot = (): RequestMuteBot => ({
type: REQUEST_MUTE_BOT,
});
export const requestMuteBotFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<InjectionStream>): RequestMuteBotFinished => ({
type: REQUEST_MUTE_BOT_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,21 @@
import { NewInjectionStreamDrawerOpenParameters } from "../../../models/calls/types";
import BaseAction from "../../base/BaseAction";
export const OPEN_NEW_INJECTION_STREAM_DRAWER = 'OPEN_NEW_INJECTION_STREAM_DRAWER';
export const CLOSE_NEW_INJECTION_STREAM_DRAWER = 'CLOSE_NEW_INJECTION_STREAM_DRAWER';
export interface OpenNewInjectionStreamDrawer extends BaseAction<NewInjectionStreamDrawerOpenParameters> {}
export interface CloseNewInjectionStreamDrawer extends BaseAction<undefined> {}
export const openNewInjectionStreamDrawer = ({
callId,
}: NewInjectionStreamDrawerOpenParameters): OpenNewInjectionStreamDrawer => ({
type: OPEN_NEW_INJECTION_STREAM_DRAWER,
payload: {
callId,
},
});
export const closeNewInjectionStreamDrawer = (): CloseNewInjectionStreamDrawer =>({
type: CLOSE_NEW_INJECTION_STREAM_DRAWER,
})

Просмотреть файл

@ -0,0 +1,27 @@
import { NewStreamDrawerOpenParameters } from "../../../models/calls/types";
import BaseAction from "../../base/BaseAction";
export const OPEN_NEW_STREAM_DRAWER = 'OPEN_NEW_STREAM_DRAWER';
export const CLOSE_NEW_STREAM_DRAWER = 'CLOSE_NEW_STREAM_DRAWER';
export interface OpenNewStreamDrawer extends BaseAction<NewStreamDrawerOpenParameters> {}
export interface CloseNewStreamDrawer extends BaseAction<undefined> {}
export const openNewStreamDrawer = ({
callId,
streamType,
participantId,
participantName,
}: NewStreamDrawerOpenParameters): OpenNewStreamDrawer => ({
type: OPEN_NEW_STREAM_DRAWER,
payload: {
callId,
streamType,
participantId,
participantName,
},
});
export const closeNewStreamDrawer = (): CloseNewStreamDrawer =>({
type: CLOSE_NEW_STREAM_DRAWER,
})

Просмотреть файл

@ -0,0 +1,23 @@
import { CallStreamKey } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_REFRESH_STREAM_KEY = 'REQUEST_REFRESH_STREAM_KEY';
export const REQUEST_REFRESH_STREAM_KEY_FINISHED = 'REQUEST_REFRESH_STREAM_KEY_FINISHED';
export interface RequestRefreshStreamKey extends BaseAction<undefined> {}
export interface RequestRefreshStreamKeyFinished extends BaseAction<RequestResponse<CallStreamKey>> {}
export const requestRefreshStreamKey = (): RequestRefreshStreamKey => ({
type: REQUEST_REFRESH_STREAM_KEY,
});
export const requestRefreshStreamKeyFinished = ({
payload,
meta
}: RequestFinishedActionParameters<CallStreamKey>): RequestRefreshStreamKeyFinished => ({
type: REQUEST_REFRESH_STREAM_KEY_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { InjectionStream } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_START_INJECTION_STREAM = 'REQUEST_START_INJECTION_STREAM';
export const REQUEST_START_INJECTION_STREAM_FINISHED = 'REQUEST_START_INJECTION_STREAM_FINISHED';
export interface RequestStartInjectionStream extends BaseAction<undefined> {}
export interface RequestStartInjectionStreamFinished extends BaseAction<RequestResponse<InjectionStream>> {}
export const requestStartInjectionStream = (): RequestStartInjectionStream => ({
type: REQUEST_START_INJECTION_STREAM,
});
export const requestStartInjectionStreamFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<InjectionStream>): RequestStartInjectionStreamFinished => ({
type: REQUEST_START_INJECTION_STREAM_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { Stream } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse, Resource } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_START_STREAM = 'REQUEST_START_STREAM';
export const REQUEST_START_STREAM_FINISHED = 'REQUEST_START_STREAM_FINISHED';
export interface RequestStartStream extends BaseAction<undefined> {}
export interface RequestStartStreamFinished extends BaseAction<RequestResponse<Resource<Stream>>> {}
export const requestStartStream = (): RequestStartStream => ({
type: REQUEST_START_STREAM,
});
export const requestStartStreamFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<Stream>>): RequestStartStreamFinished => ({
type: REQUEST_START_STREAM_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { InjectionStream } from "../../../models/calls/types";
import { ApiError } from "../../../models/error/types";
import { RequestResponse } from "../../../services/api";
import BaseAction, { RequestFinishedActionParameters } from "../../base/BaseAction";
export const REQUEST_STOP_INJECTION_STREAM = 'REQUEST_STOP_INJECTION_STREAM';
export const REQUEST_STOP_INJECTION_STREAM_FINISHED = 'REQUEST_STOP_INJECTION_STREAM_FINISHED';
export interface RequestStoptInjectionStream extends BaseAction<undefined> {}
export interface RequestStoptInjectionStreamFinished extends BaseAction<RequestResponse<InjectionStream>> {}
export const requestStoptInjectionStream = (): RequestStoptInjectionStream => ({
type: REQUEST_STOP_INJECTION_STREAM,
});
export const requestStoptInjectionStreamFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<InjectionStream>): RequestStoptInjectionStreamFinished => ({
type: REQUEST_STOP_INJECTION_STREAM_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { Stream } from '../../../models/calls/types';
import { ApiError } from '../../../models/error/types';
import { RequestResponse, Resource } from '../../../services/api';
import BaseAction, { RequestFinishedActionParameters } from '../../base/BaseAction';
export const REQUEST_STOP_STREAM = 'REQUEST_STOP_STREAM';
export const REQUEST_STOP_STREAM_FINISHED = 'REQUEST_STOP_STREAM_FINISHED';
export interface RequestStopStream extends BaseAction<undefined> {}
export interface RequestStopStreamFinished extends BaseAction<RequestResponse<Resource<Stream>>> {}
export const requestStopStream = (): RequestStopStream => ({
type: REQUEST_STOP_STREAM,
});
export const requestStopStreamFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<Stream>>): RequestStopStreamFinished => ({
type: REQUEST_STOP_STREAM_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { InjectionStream } from "../../../models/calls/types";
import { ApiError } from "../../../models/error/types";
import { RequestResponse } from "../../../services/api";
import BaseAction, { RequestFinishedActionParameters } from "../../base/BaseAction";
export const REQUEST_UNMUTE_BOT = 'REQUEST_UNMUTE_BOT';
export const REQUEST_UNMUTE_BOT_FINISHED = 'REQUEST_UNMUTE_BOT_FINISHED';
export interface RequestUnmuteBot extends BaseAction<undefined> {}
export interface RequestUnmuteBotFinished extends BaseAction<RequestResponse<InjectionStream>> {}
export const requestUnmuteBot = (): RequestUnmuteBot => ({
type: REQUEST_UNMUTE_BOT,
});
export const requestUnmuteBotFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<InjectionStream>): RequestUnmuteBotFinished => ({
type: REQUEST_UNMUTE_BOT_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});

Просмотреть файл

@ -0,0 +1,23 @@
import { CallDefaults } from '../../../models/calls/types';
import BaseAction from '../../base/BaseAction';
export const UPDATE_CALL_DEFAULTS = 'UPDATE_CALL_DEFAULTS';
export interface UpdateCallDefaults
extends BaseAction<{
callId: string;
defaults: CallDefaults;
}> {}
export const updateCallDefaults = ({
callId,
defaults,
}: {
callId: string;
defaults: CallDefaults;
}): UpdateCallDefaults => ({
type: UPDATE_CALL_DEFAULTS,
payload: {
callId,
defaults,
},
});

Просмотреть файл

@ -0,0 +1,419 @@
import {push} from 'connected-react-router';
import {AnyAction} from 'redux';
import {ThunkAction} from 'redux-thunk';
import {
Call,
CallState,
CallStreamKey,
InjectionStream,
NewInjectionStream,
StartStreamRequest,
StopStreamRequest,
Stream,
StreamSrtConfiguration
} from '../../models/calls/types';
import { ApiError } from '../../models/error/types';
import {ApiClient, Resource} from '../../services/api';
import IAppState from '../../services/store/IAppState';
import {
closeNewInjectionStreamDrawer,
closeNewStreamDrawer,
requestActiveCalls,
requestActiveCallsFinished,
requestCall,
requestCallFinished,
RequestCallType,
requestDisconnectCall,
requestDisconnectCallFinished,
requestJoinCall,
requestJoinCallFinished,
requestMuteBot,
requestMuteBotFinished,
requestPollingCall,
requestPollingCallFinished,
requestRefreshStreamKey,
requestRefreshStreamKeyFinished,
requestStartInjectionStream,
requestStartInjectionStreamFinished,
requestStartStream,
requestStartStreamFinished,
requestStopStream,
requestStopStreamFinished,
requestUnmuteBot,
requestUnmuteBotFinished
} from './actions';
const POLL_INTERVAL = 1000;
const CALL_DETAILS_PATH = '/call/details/';
export const joinCallAsync = (callUrl : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestJoinCall(callUrl));
const joinCallResponse = await ApiClient.post<Call>({
url: '/call/initialize-call',
payload: {
MeetingUrl: callUrl
},
isSecured: true
});
/*
NOTE: Before this change, we didn't update the call state until it was
with Established State. Now we dispatch the action to update the status of the API call
in the application state, and also add the call to the state.
*/
dispatch(requestJoinCallFinished({payload: joinCallResponse}));
const isError: boolean = joinCallResponse instanceof ApiError;
if (! isError) {
const call = joinCallResponse as Call;
dispatch(push(`/call/details/${
call.id
}`));
}
};
export const oldjoinCallAsync = (callUrl : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestJoinCall(callUrl));
const joinCallResponse = await ApiClient.post<Call>({
url: '/call/initialize-call',
payload: {
MeetingUrl: callUrl
},
isSecured: true
});
/*
NOTE: Before this change, we didn't update the call state until it was
with Established State. Now we dispatch the action to update the status of the API call
in the application state, and also add the call to the state.
*/
dispatch(requestJoinCallFinished({payload: joinCallResponse}));
const joinCallReponseIsError: boolean = joinCallResponse instanceof ApiError;
const callId = joinCallReponseIsError ? undefined : (joinCallResponse as Call).id;
/*
TODO: Suggestion:
Analyze where we should do the polling.
Try to remove it from the async action.
*/
const pollCall = async () => {
dispatch(requestCall());
const callResponse = await ApiClient.get<Call>({url: `/call/${callId}`, isSecured: true});
dispatch(requestCallFinished({payload: callResponse}));
const isError: boolean = callResponse instanceof ApiError;
if (isError) { /*
TODO: Question:
if we have an error when the app starts polling,
should we keep polling or just return?
*/
return;
}
const call = callResponse as Call;
/*
NOTE: Now we update the state of the call after getting the response
independently of its state
*/
switch (call.state) {
case CallState.Establishing:
// keep polling
return setTimeout(pollCall, POLL_INTERVAL);
case CallState.Established:
dispatch(push(`/call/details/${
call.id
}`));
return;
case CallState.Terminated:
/*
TODO: Question
Before, we were dispatching a error we weren't using.
Should we do something? Might we add a toast?
*/
return;
default:
return;
}
};
if (! joinCallReponseIsError) {
await pollCall();
}
};
export const disconnectCallAsync = (callId : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
const call = getState().calls.activeCalls.find((call) => call.id === callId);
if (call) {
dispatch(requestDisconnectCall());
const disonnectCallResponse = await ApiClient.delete<Resource<Call>>({url: `/call/${callId}`, isSecured: true});
/*
TODO: Review
At the moment, when we disconnect a call, we just update the call in the state.
Should we remove it from the state?
*/
dispatch(requestDisconnectCallFinished({payload: disonnectCallResponse}));
}
};
/*
TODO: Suggestion/Review:
We should change the way we poll for the current call and do
something similar to what we do in the Meeting Extension.
*/
export const pollCurrentCallAsync = () : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
const poll = async () => {
const path = getState().router.location.pathname;
if (! path.startsWith(CALL_DETAILS_PATH)) {
return setTimeout(poll, POLL_INTERVAL);
}
// User is on the CallDetails view
// Check if the call is already in state, if not, push a 'Loading' call placeholder for this one
const callId = path.split(CALL_DETAILS_PATH).pop();
if (! callId) {
return setTimeout(poll, POLL_INTERVAL);
}
// poll data
const existingCall = getState().calls.activeCalls.find((call) => call.id === callId);
// Do not refresh if call already terminated
if (! existingCall || existingCall.state !== CallState.Terminated) {
dispatch(requestPollingCall());
const requestCallResponse = await ApiClient.get<Call>({url: `/call/${callId}`, isSecured: true});
dispatch(requestPollingCallFinished({
payload: requestCallResponse,
meta: existingCall ? RequestCallType.ExistingCall : RequestCallType.NewCall
}));
const isError: boolean = requestCallResponse instanceof ApiError;
if (isError) {
/*
TODO: Question:
if we have an error when the app starts polling,
should we keep polling or just return?
*/
// Simulate room disconnection and redirect home
dispatch(push('/'));
// Enqueue
return setTimeout(poll, POLL_INTERVAL);
}
}
return setTimeout(poll, POLL_INTERVAL);
};
// trigger polling
return setTimeout(poll, POLL_INTERVAL);
};
export const startStreamAsync = ({callId, participantId, protocol, config} : StartStreamRequest) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
const state = getState();
const call = state.calls.activeCalls.find((call) => call.id == callId);
if (! call) {
return;
}
const stream = call.streams.find((stream) => stream.id === participantId);
if (! stream) {
return;
}
dispatch(requestStartStream());
const startStreamResponse = await ApiClient.post<Resource<Stream>>({
url: `/call/${
call.id
}/stream/start-extraction`,
payload: {
callId: call.id,
resourceType: stream.type,
participantId: stream.id,
participantGraphId: stream.participantGraphId,
protocol: protocol,
latency: (config as StreamSrtConfiguration).latency,
mode: (config as StreamSrtConfiguration).mode,
streamUrl: config.streamUrl || null,
streamKey: config.streamKey || null,
timeOverlay: config.timeOverlay,
audioFormat: config.audioFormat
},
isSecured: true
});
dispatch(requestStartStreamFinished({payload: startStreamResponse}));
/*
TODO: Review
We should analyze how to handle UI state in the application state, to improve the semantic
of the code, and make it more readable or understandable.
*/
dispatch(closeNewStreamDrawer());
};
export const stopStreamAsync = ({callId, type, participantId, participantName} : StopStreamRequest) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
const state = getState();
const call = state.calls.activeCalls.find((o) => o.id === callId);
if (! call) {
return;
}
const stream = call.streams.find((o) => o.id === participantId);
if (! stream) {
return;
}
dispatch(requestStopStream());
// call api
const stopStreamResponse = await ApiClient.post<Resource<Stream>>({
url: `/call/${
call.id
}/stream/stop-extraction`,
isSecured: true,
payload: {
callId: call.id,
resourceType: stream.type,
participantId: stream.id,
participantGraphId: stream.participantGraphId
}
});
dispatch(requestStopStreamFinished({payload: stopStreamResponse}));
};
export const getActiveCallsAsync = () : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestActiveCalls());
const activeCallsResponse = await ApiClient.get<Call[]>({url: '/call/active', isSecured: true});
console.log({
activeCallsResponse
})
dispatch(requestActiveCallsFinished({payload: activeCallsResponse}));
};
export const refreshStreamKeyAsync = (callId: string) : ThunkAction<
void,
IAppState,
undefined,
AnyAction> => async (dispatch, getState) => {
dispatch(requestRefreshStreamKey());
const refreshStreamKeyResponse = await ApiClient.post<CallStreamKey>({
url: `/call/${callId}/generate-stream-key`,
isSecured: true,
});
dispatch(requestRefreshStreamKeyFinished({payload: refreshStreamKeyResponse}));
};
export const startInjectionAsync = ({
callId,
streamUrl,
streamKey,
protocol,
mode,
latency,
enableSsl
} : NewInjectionStream) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => { // TODO: Review this action dispatch(injectionRequestCancelled());
dispatch(requestStartInjectionStream());
// Call API
const startInjectionResponse = await ApiClient.post<InjectionStream>({
url: `/call/${callId}/stream/start-injection`,
isSecured: true,
payload: {
callId,
streamUrl,
streamKey,
protocol,
mode,
latency,
enableSsl
}
});
dispatch(requestStartInjectionStreamFinished({payload: startInjectionResponse}));
dispatch(closeNewInjectionStreamDrawer());
};
export const stopInjectionAsync = (callId : string, streamId : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestStartInjectionStream());
const startInjectionResponse = await ApiClient.post<InjectionStream>({url: `/call/${callId}/stream/${streamId}/stop-injection`, isSecured: true});
dispatch(requestStartInjectionStreamFinished({payload: startInjectionResponse}));
};
export const muteBotAsync = (callId : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestMuteBot());
const muteBotResponse = await ApiClient.post<InjectionStream>({url: `/call/${callId}/mute`, isSecured: true});
dispatch(requestMuteBotFinished({payload: muteBotResponse}));
};
export const unmuteBotAsync = (callId : string) : ThunkAction < void,
IAppState,
undefined,
AnyAction > => async (dispatch, getState) => {
dispatch(requestUnmuteBot());
const muteBotResponse = await ApiClient.post<InjectionStream>({url: `/call/${callId}/unmute`, isSecured: true});
dispatch(requestUnmuteBotFinished({payload: muteBotResponse}));
};

350
src/stores/calls/reducer.ts Normal file
Просмотреть файл

@ -0,0 +1,350 @@
import { Reducer } from 'redux';
import {
Call,
CallState,
CallStreamKey,
InjectionStream,
NewCall,
NewInjectionStream,
NewStream,
Stream,
StreamProtocol,
StreamType,
} from '../../models/calls/types';
import { Resource } from '../../services/api';
import baseReducer from '../base/BaseReducer';
import * as CallsActions from './actions';
import { RequestCallType } from './actions';
export interface ICallsState {
activeCalls: Call[];
newCall: null | NewCall;
newStream: null | NewStream;
newInjectionStream: null | NewInjectionStream;
activeCallsLoading: boolean;
activeCallsError: null | string;
}
export const INITIAL_STATE: ICallsState = {
newStream: null,
newInjectionStream: null,
newCall: null,
activeCalls: [],
activeCallsLoading: false,
activeCallsError: null,
};
/*
NOTE: There are some REQUEST_*_FINISHED actions we don't add to the reducer
because the call polling already updates the state of the call (with its streams)
*/
export const callsReducer: Reducer = baseReducer(INITIAL_STATE, {
[CallsActions.REQUEST_ACTIVE_CALLS_FINISHED](state: ICallsState, action: CallsActions.RequestActiveCallsFinished): ICallsState {
const calls = action.payload! as Call[];
return {
...state,
activeCalls: calls.map((call) => fillDefaults(call, defaultCallValues)),
}
},
[CallsActions.REQUEST_JOIN_CALL](state: ICallsState, action: CallsActions.RequestJoinCall): ICallsState {
return {
...state,
newCall: {
callUrl: action.payload!.callUrl,
status: CallState.Establishing,
},
};
},
[CallsActions.REQUEST_JOIN_CALL_FINISHED](
state: ICallsState,
action: CallsActions.RequestJoinCallFinished
): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
*/
// add new call to active calls
const call = action.payload! as Call;
const callWitDefaults = fillDefaults(call, defaultCallValues);
return {
...state,
newCall: null,
activeCalls: state.activeCalls.concat(callWitDefaults),
};
},
[CallsActions.REQUEST_CALL_FINISHED](state: ICallsState, action: CallsActions.RequestCallFinished): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
*/
const call = action.payload! as Call;
// Update the call in question
const existingCall = state.activeCalls.find((o) => o.id === call.id);
const callWitDefaults = fillDefaults(call, existingCall ?? defaultCallValues);
return {
...state,
activeCalls: state.activeCalls.map((o) => (o.id !== callWitDefaults.id ? o : callWitDefaults)),
};
},
[CallsActions.REQUEST_POLLING_CALL_FINISHED](
state: ICallsState,
action: CallsActions.RequestPollingCallFinished
): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
*/
const requestCallType = action.meta! as RequestCallType;
const call = action.payload! as Call;
if (requestCallType === RequestCallType.NewCall) {
const callWitDefaults = fillDefaults(call, defaultCallValues);
return {
...state,
newCall: null,
activeCalls: state.activeCalls.concat(callWitDefaults),
};
}
if (requestCallType === RequestCallType.ExistingCall) {
const existingCall = state.activeCalls.find((o) => o.id === call.id);
const callWitDefaults = fillDefaults(call, existingCall ?? defaultCallValues);
return {
...state,
activeCalls: state.activeCalls.map((o) => (o.id !== callWitDefaults.id ? o : callWitDefaults)),
};
}
return {
...state,
};
},
[CallsActions.REQUEST_DISCONNECT_CALL_FINISHED](
state: ICallsState,
action: CallsActions.RequestDisconnectCallFinished
): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
TODO: Review
We should analyze why we use the call updated action in our previous reducer/async action
to handle the call disconnection.
*/
const call = action.payload! as Resource<Call>;
// Update the call in question
const existingCall = state.activeCalls.find((o) => o.id === call.id);
const callWitDefaults = fillDefaults(call.resource, existingCall ?? defaultCallValues);
return {
...state,
activeCalls: state.activeCalls.map((o) => (o.id !== callWitDefaults.id ? o : callWitDefaults)),
};
},
[CallsActions.REQUEST_START_STREAM_FINISHED](
state: ICallsState,
action: CallsActions.RequestStartStreamFinished
): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
*/
const resource = action.payload! as Resource<Stream>;
const updatedStream: Stream = {
...resource.resource,
};
return {
...state,
activeCalls: state.activeCalls.map((call) =>
call.id === updatedStream.callId
? {
// call in question
...call,
streams: call.streams.map((stream) => (stream.id === updatedStream.id ? updatedStream : stream)),
} // other call
: call
),
};
},
[CallsActions.REQUEST_STOP_STREAM_FINISHED](
state: ICallsState,
action: CallsActions.RequestStopStreamFinished
): ICallsState {
/*
NOTE: If the action is exceuted, is because it is not flagged as error,
so we can infer the payload type
*/
const resource = action.payload! as Resource<Stream>;
const updatedStream: Stream = {
...resource.resource,
};
return {
...state,
activeCalls: state.activeCalls.map((call) =>
call.id === updatedStream.callId
? {
// call in question
...call,
streams: call.streams.map((stream) => (stream.id === updatedStream.id ? updatedStream : stream)),
} // other call
: call
),
};
},
[CallsActions.OPEN_NEW_STREAM_DRAWER](state: ICallsState, action: CallsActions.OpenNewStreamDrawer): ICallsState {
const payload = action.payload!;
const call = state.activeCalls.find((o) => o.id === payload.callId);
if (!call) {
return state;
}
return {
...state,
newStream: {
callId: payload.callId,
participantId: payload.participantId,
streamType: payload.streamType,
participantName: payload.participantName,
advancedSettings: {
latency: call.defaultLatency,
key: call.defaultPassphrase,
unmixedAudio: false,
},
},
};
},
[CallsActions.CLOSE_NEW_STREAM_DRAWER](state: ICallsState, action: CallsActions.CloseNewStreamDrawer): ICallsState {
return {
...state,
newStream: null,
};
},
[CallsActions.OPEN_NEW_INJECTION_STREAM_DRAWER](
state: ICallsState,
action: CallsActions.OpenNewInjectionStreamDrawer
): ICallsState {
const payload = action.payload!;
const call = state.activeCalls.find((o) => o.id === payload.callId);
if (!call) {
return state;
}
return {
...state,
newInjectionStream: {
callId: call.id,
},
};
},
[CallsActions.CLOSE_NEW_INJECTION_STREAM_DRAWER](
state: ICallsState,
action: CallsActions.CloseNewInjectionStreamDrawer
): ICallsState {
return {
...state,
newInjectionStream: null,
};
},
[CallsActions.REQUEST_START_INJECTION_STREAM_FINISHED](
state: ICallsState,
action: CallsActions.RequestStartInjectionStreamFinished
): ICallsState {
const stream = action.payload! as InjectionStream;
const callId = stream.callId;
const call = state.activeCalls.find((c) => c.id === callId);
if (!call) {
return state;
}
return {
...state,
activeCalls: state.activeCalls.map((call) =>
call.id === stream.callId
? {
...call,
injectionStream: stream,
}
: call
),
};
},
[CallsActions.UPDATE_CALL_DEFAULTS](state: ICallsState, action: CallsActions.UpdateCallDefaults): ICallsState {
const payload = action.payload!;
const defaults = payload.defaults;
const call = state.activeCalls.find((o) => o.id === payload.callId);
if (!call) {
return state;
}
const updated: Call = {
...call,
defaultProtocol: defaults.protocol,
defaultLatency: defaults.latency ?? call.defaultLatency,
defaultPassphrase: defaults.passphrase ?? call.defaultPassphrase,
};
return {
...state,
activeCalls: state.activeCalls.map((o) => (o.id !== call.id ? o : updated)),
};
},
[CallsActions.REQUEST_REFRESH_STREAM_KEY_FINISHED](
state: ICallsState,
action: CallsActions.RequestRefreshStreamKeyFinished
): ICallsState {
/*
NOTE: If the action is executed, is because it is not flagged as error,
so we can infer the payload type
*/
const payload = action.payload! as CallStreamKey;
const call = state.activeCalls.find((o) => o.id === payload.callId);
if (!call) {
return state;
}
const updated: Call = {
...call,
privateContext: {
streamKey: payload.streamKey,
},
};
return {
...state,
activeCalls: state.activeCalls.map((o) => (o.id !== call.id ? o : updated)),
};
},
});
const defaultCallValues = {
defaultLatency: 750,
defaultPassphrase: '',
};
const fillDefaults = (call: Call, defaults: Partial<Call>): Call => ({
...call,
defaultLatency: defaults.defaultLatency ?? defaultCallValues.defaultLatency,
defaultPassphrase: defaults.defaultPassphrase ?? defaultCallValues.defaultPassphrase,
defaultProtocol: defaults.defaultProtocol ?? StreamProtocol.SRT,
streams: call.streams
? call.streams.map((o) => ({
...o,
audioSharing: o.type !== StreamType.VbSS,
}))
: [],
});

Просмотреть файл

@ -0,0 +1,147 @@
import { createSelector} from 'reselect';
import { ActiveStatuses, Call, CallState, CallType, InactiveStatuses, SpecialStreamTypes, StreamProtocol, StreamType } from '../../models/calls/types';
import IAppState from '../../services/store/IAppState';
import { CallInfoProps, CallStreamsProps, NewInjectionStreamDrawerProps, NewStreamDrawerProps } from '../../views/call-details/types';
import { ICallsState } from './reducer';
export const selectNewCall = createSelector(
(state: IAppState) => state.calls,
(state: ICallsState) => state.newCall
);
export const selectActiveCalls = createSelector(
(state: IAppState) => state.calls,
(state: ICallsState) => state.activeCalls
);
export const selectCallStreams = createSelector(
(state: IAppState) => state.calls,
(state: IAppState, callId: string) => callId,
_selectCallStreams
)
export const selectNewInjectionStreamDrawerProps = createSelector(
(state: IAppState) => state.calls,
(state: IAppState, callId: string) => callId,
_selectNewInjectionStreamDrawerProps
)
export const selectNewStreamDrawerProps = createSelector(
(state: IAppState) => state.calls,
(state: IAppState, callId: string) => callId,
_selectNewStreamDrawerProps
)
export const selectCallInfoProps = createSelector(
(state: IAppState) => state.calls,
(state: IAppState, callId: string) => callId,
_selectCallInfoProps
)
function _selectCallStreams(callState: ICallsState, callId: string): CallStreamsProps {
const call = callState.activeCalls.find(call => call.id === callId);
if(!call){
return {
callId,
callEnabled: false,
mainStreams: [],
participantStreams: [],
activeStreams: [],
primarySpeakerEnabled: false,
stageEnabled: false,
injectionStream: null,
callProtocol: 0,
}
}
return {
callId,
callEnabled: call.state === CallState.Established,
mainStreams: call.streams.filter((o) => SpecialStreamTypes.includes(o.type) && InactiveStatuses.includes(o.state)),
participantStreams: call.streams.filter(
(o) => o.type === StreamType.Participant && InactiveStatuses.includes(o.state)
),
activeStreams: call.streams.filter((o) => ActiveStatuses.includes(o.state)),
primarySpeakerEnabled:
call.streams.filter(
(o) => o.type === StreamType.Participant && o.isSharingVideo && o.isSharingAudio && !o.audioMuted
).length > 0,
stageEnabled: call.streams.filter((o) => o.type === StreamType.Participant && o.isSharingScreen).length > 0,
injectionStream: call.injectionStream,
callProtocol: call.defaultProtocol,
}
}
function _selectNewInjectionStreamDrawerProps(callState: ICallsState, callId: string): NewInjectionStreamDrawerProps {
const call = callState.activeCalls.find((o) => o.id === callId);
const newInjectionStream = callState.newInjectionStream;
if (!call) {
return {
call: null,
newInjectionStream,
};
}
return {
call,
newInjectionStream,
};
}
function _selectNewStreamDrawerProps(callState: ICallsState, callId: string): NewStreamDrawerProps {
const call = callState.activeCalls.find((o) => o.id === callId);
const newStream = callState.newStream;
if (!call) {
return {
call: null,
newStream,
};
}
return {
call,
newStream,
};
}
function _selectCallInfoProps(callState: ICallsState, callId: string): CallInfoProps {
const call = callState.activeCalls.find((o) => o.id === callId);
console.log('streams:', call?.streams.length);
if (!call) {
return {
call: {
...CALL_INITIALIZING_PLACEHOLDER,
},
streams: [],
};
}
return {
call,
streams: call.streams,
};
}
const CALL_INITIALIZING_PLACEHOLDER: Call = {
id: '0',
displayName: 'Loading Call',
botFqdn: '',
botIp: '',
connectionPool: {
available: 0,
used: 0,
},
createdAt: new Date(),
defaultProtocol: StreamProtocol.SRT,
defaultLatency: 0,
defaultPassphrase: '',
errorMessage: null,
joinUrl: '',
state: CallState.Establishing,
meetingType: CallType.Default,
streams: [],
injectionStream: null,
privateContext: null,
};

Просмотреть файл

@ -0,0 +1,20 @@
import { ApiError } from '../../models/error/types';
import BaseAction from '../base/BaseAction';
import { AppConfig } from './types';
export const LOAD_CONFIG = 'LOAD_CONFIG';
export const LOAD_CONFIG_ERROR = 'LOAD_CONFIG_ERROR';
export interface LoadConfig extends BaseAction<AppConfig> {}
export interface LoadConfigError extends BaseAction<ApiError> {}
export const loadConfig = (payload: AppConfig): LoadConfig => ({
type: LOAD_CONFIG,
payload: payload,
});
export const loadConfigError = (error: ApiError): LoadConfigError => ({
type: LOAD_CONFIG_ERROR,
payload: error,
error: true,
});

Просмотреть файл

@ -0,0 +1,14 @@
import { AnyAction } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IAppState from '../../services/store/IAppState';
import { getConfig } from './loader';
import { loadConfig as loadConfigAction, loadConfigError } from './actions';
export const loadConfig = (): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch, getState) => {
try {
const config = await getConfig();
dispatch(loadConfigAction(config));
} catch (error) {
dispatch(loadConfigError(error));
}
};

Просмотреть файл

@ -0,0 +1 @@
export const FEATUREFLAG_DISABLE_AUTHENTICATION = "DISABLE_AUTHENTICATION";

Просмотреть файл

@ -0,0 +1,17 @@
import { AppConfig } from './types';
import Axios from 'axios';
import { DefaultError } from '../../models/error/types';
const configUrl = '/config.json';
const loader = new Promise<AppConfig>((resolve, reject) => {
Axios.get(configUrl)
.then((o) => resolve(o.data as AppConfig))
.catch((err) => {
console.log('Error loading config:', err);
const errorResponse = new DefaultError('Error loading config', err);
reject(errorResponse);
});
});
export const getConfig = (): Promise<AppConfig> => loader;

Просмотреть файл

@ -0,0 +1,21 @@
import baseReducer from "../base/BaseReducer";
import { AppConfig } from "./types";
import * as ConfigActions from "./actions";
export interface ConfigState {
initialized: boolean;
app: AppConfig | null;
}
export const INITIAL_STATE: ConfigState = {
initialized: false,
app: null,
}
export const configReducer = baseReducer(INITIAL_STATE, {
[ConfigActions.LOAD_CONFIG](state: ConfigState, action: ConfigActions.LoadConfig): ConfigState{
return {
initialized: true,
app: action.payload!
}
}
})

Просмотреть файл

@ -0,0 +1,25 @@
export interface AppConfig {
buildNumber: string;
apiBaseUrl: string;
msalConfig: MsalConfig;
featureFlags: FeatureFlags | undefined;
}
export interface BaseFeatureFlag {
description: string;
isActive: boolean;
}
export type FeatureFlagsTypes = BaseFeatureFlag;
export interface FeatureFlags {
readonly [key: string]: FeatureFlagsTypes;
}
export interface MsalConfig {
spaClientId: string,
apiClientId: string,
groupId: string,
authority: string,
redirectUrl: string
}

Просмотреть файл

@ -0,0 +1,17 @@
import BaseAction from '../base/BaseAction';
export const REMOVE_ERROR: string = 'REMOVE_ERROR';
export const CLEAR_ALL_ERROR: string = 'CLEAR_ALL_ERROR';
export function removeById(id: string): BaseAction<string> {
return {
type: REMOVE_ERROR,
payload: id,
};
}
export function clearAll(): BaseAction<undefined> {
return {
type: CLEAR_ALL_ERROR,
};
}

Просмотреть файл

@ -0,0 +1,96 @@
/*
* Note: This reducer breaks convention on how reducers should be setup.
*/
import { ApiError, ApplicationError, DefaultError } from '../../models/error/types';
import BaseAction from '../base/BaseAction';
import * as ErrorAction from './actions';
export interface ErrorState {
[key: string]: ApplicationError;
}
export const initialState: ErrorState = {};
export default function errorReducer(state: ErrorState = initialState, action: BaseAction<any>): ErrorState {
const { type, error, payload } = action;
/*
* Removes an ErrorRseponse by it's id that is in the action payload.
*/
if (type === ErrorAction.REMOVE_ERROR) {
// Create a new state without the error that has the same id as the payload.
return Object.entries(state).reduce((newState: ErrorState, [key, value]: [string, ApplicationError]) => {
if (value.id !== payload) {
newState[key] = value;
}
return newState;
}, {});
}
/*
* Removes all errors by returning the initial state which is an empty object.
*/
if (type === ErrorAction.CLEAR_ALL_ERROR) {
return initialState;
}
/*
* Checking if is a default error
*/
const isDefaultError = payload instanceof DefaultError && Boolean(error);
if (isDefaultError) {
//Adds the default error
return {
...state,
[type]: payload,
};
}
/*
* APi Errors logic
*/
/*
* True if the action type has the key word '_FINISHED' then the action is finished.
*/
const isFinishedRequestType = type.includes('_FINISHED');
/*
* True if the action type has the key word 'REQUEST_' and not '_FINISHED'.
*/
const isStartRequestType = type.includes('REQUEST_') && !isFinishedRequestType;
/*
* If an action is started we want to remove any old errors because there is a new action has been re-dispatched.
*/
if (isStartRequestType) {
// Using ES7 Object Rest Spread operator to omit properties from an object.
const { [`${type}_FINISHED`]: value, ...stateWithoutFinishedType } = state;
return stateWithoutFinishedType;
}
/*
* True if the action is finished and the error property is true.
*/
const isError: boolean = isFinishedRequestType && Boolean(error);
/*
* For any start and finished actions that don't have errors we return the current state.
*/
if (isError === false) {
return state;
}
/*
* At this point the "type" will be a finished action type (e.g. "SomeAction.REQUEST_*_FINISHED").
* The payload will be a ErrorRseponse.
*/
return {
...state,
[type]: payload,
};
}

Просмотреть файл

@ -0,0 +1,31 @@
/*
* Note: This reducer breaks convention on how reducers should be setup.
*/
import BaseAction from "../base/BaseAction";
export interface RequestingState {
readonly [key: string]: boolean;
}
export const initialState: RequestingState = {};
export default function requestingReducer(state: RequestingState = initialState, action: BaseAction<any>): RequestingState {
// We only take actions that include 'REQUEST_' in the type.
const isRequestType: boolean = action.type.includes('REQUEST_');
if (isRequestType === false) {
return state;
}
// Remove the string '_FINISHED' from the action type so we can use the first part as the key on the state.
const requestName: string = action.type.replace('_FINISHED', '');
// If the action type includes '_FINISHED'. The boolean value will be false. Otherwise we
// assume it is a starting request and will be set to true.
const isFinishedRequestType: boolean = action.type.includes('_FINISHED');
return {
...state,
[requestName]: isFinishedRequestType === false,
};
}

Просмотреть файл

@ -0,0 +1,13 @@
import { createSelector, ParametricSelector } from "reselect";
import IAppState from "../../services/store/IAppState";
import { RequestingState } from "./reducer";
export const selectRequesting: ParametricSelector<IAppState, string[], boolean> = createSelector(
(state: IAppState) => state.requesting,
(state: IAppState, actionTypes: string[]) => actionTypes,
_selectRequesting
);
function _selectRequesting(requestingState: RequestingState, actionTypes: string[]): boolean {
return actionTypes.some((actionType: string) => requestingState[actionType]);
}

Просмотреть файл

@ -0,0 +1,90 @@
import { ApiError, DefaultError } from "../../models/error/types";
import { BotService } from "../../models/service/types";
import { RequestResponse, Resource } from "../../services/api";
import BaseAction, { RequestFinishedActionParameters } from "../base/BaseAction";
export const REQUEST_START_SERVICE = "REQUEST_START_SERVICE";
export const REQUEST_START_SERVICE_FINISHED = "REQUEST_START_SERVICE_FINISHED";
export interface RequestStartService extends BaseAction<undefined> {}
export interface RequestStartServiceFinished extends BaseAction<RequestResponse<Resource<BotService>>> {}
export const requestStartService = (): RequestStartService => ({
type: REQUEST_START_SERVICE,
});
export const requestStartServiceFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<BotService>>): RequestStartServiceFinished => ({
type: REQUEST_START_SERVICE_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});
export const REQUEST_STOP_SERVICE = "REQUEST_STOP_SERVICE";
export const REQUEST_STOP_SERVICE_FINISHED = "REQUEST_STOP_SERVICE_FINISHED";
export interface RequestStopService extends BaseAction<undefined> {}
export interface RequestStopServiceFinished extends BaseAction<RequestResponse<Resource<BotService>>> {}
export const requestStopService = (): RequestStopService => ({
type: REQUEST_STOP_SERVICE,
});
export const requestStopServiceFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<BotService>>): RequestStopServiceFinished => ({
type: REQUEST_STOP_SERVICE_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});
export const REQUEST_BOT_SERVICE = "REQUEST_BOT_SERVICE";
export const REQUEST_BOT_SERVICE_FINISHED = "REQUEST_BOT_SERVICE_FINISHED";
export interface RequestBotService extends BaseAction<undefined> {}
export interface RequestBotServiceFinished extends BaseAction<RequestResponse<Resource<BotService>>> {}
export const requestBotService = (): RequestBotService => ({
type: REQUEST_BOT_SERVICE,
});
export const requestBotServiceFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<BotService>>): RequestBotServiceFinished => ({
type: REQUEST_BOT_SERVICE_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});
export const REQUEST_POLLING_BOT_SERVICE = "REQUEST_POLLING_BOT_SERVICE";
export const REQUEST_POLLING_BOT_SERVICE_FINISHED = "REQUEST_POLLING_BOT_SERVICE_FINISHED";
export interface RequestPollingBotService extends BaseAction<undefined> {}
export interface RequestPollingBotServiceFinished extends BaseAction<RequestResponse<Resource<BotService>>> {}
export const requestPollingBotService = (): RequestPollingBotService => ({
type: REQUEST_POLLING_BOT_SERVICE,
});
export const requestPollingBotServiceFinished = ({
payload,
meta,
}: RequestFinishedActionParameters<Resource<BotService>>): RequestPollingBotServiceFinished => ({
type: REQUEST_POLLING_BOT_SERVICE_FINISHED,
payload: payload,
error: payload instanceof ApiError,
});
export const POLLING_BOT_TRANSITION_ERROR = "POLLING_BOT_TRANSITION_ERROR";
export interface PollingBotTransitionError extends BaseAction<DefaultError> {};
export const pollingBotTransitionError = (message: string): PollingBotTransitionError => ({
type: POLLING_BOT_TRANSITION_ERROR,
payload: new DefaultError(message),
error: true,
});

Просмотреть файл

@ -0,0 +1,129 @@
import { AnyAction } from 'redux';
import { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { ApiError } from '../../models/error/types';
import { BotService, ProvisioningStateValues } from '../../models/service/types';
import { ApiClient, Resource } from '../../services/api';
import IAppState from '../../services/store/IAppState';
import {
pollingBotTransitionError,
requestBotService,
requestBotServiceFinished,
requestPollingBotService,
requestPollingBotServiceFinished,
requestStartService,
requestStartServiceFinished,
requestStopService,
requestStopServiceFinished,
} from './actions';
/*
TODO: Warning
The default service id is temporary
*/
const DEFAULT_SERVICE_ID = '00000000-0000-0000-0000-000000000000';
export const startBotServiceAsync = (): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch) => {
dispatch(requestStartService());
const startServiceResponse = await ApiClient.post<Resource<BotService>>({
url: `/service/${DEFAULT_SERVICE_ID}/start`,
isSecured: true,
});
dispatch(requestStartServiceFinished({ payload: startServiceResponse }));
const isError: boolean = startServiceResponse instanceof ApiError;
if (isError) {
return;
}
//TODO: Start polling state;
await pollFromOneStatusToAnother(ProvisioningStateValues.Provisioning, ProvisioningStateValues.Provisioned, dispatch);
};
export const stopBotServiceAsync = (): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch) => {
dispatch(requestStopService());
const stopBotServiceResponse = await ApiClient.post<Resource<BotService>>({
url: `/service/${DEFAULT_SERVICE_ID}/stop`,
isSecured: true,
});
dispatch(requestStopServiceFinished({ payload: stopBotServiceResponse }));
const isError: boolean = stopBotServiceResponse instanceof ApiError;
if (isError) {
return;
}
//TODO: Start polling state;
await pollFromOneStatusToAnother(ProvisioningStateValues.Deprovisioning, ProvisioningStateValues.Deprovisioned, dispatch);
};
export const getBotServiceAsync = (): ThunkAction<void, IAppState, undefined, AnyAction> => async (dispatch) => {
dispatch(requestBotService());
const getBotServiceResponse = await ApiClient.get<Resource<BotService>>({
url: `/service/${DEFAULT_SERVICE_ID}/state`,
isSecured: true,
});
dispatch(requestBotServiceFinished({ payload: getBotServiceResponse }));
};
const getBotServicePromise = () => {
return ApiClient.get<Resource<BotService>>({
url: `/service/${DEFAULT_SERVICE_ID}/state`,
isSecured: true,
});
};
const pollFromOneStatusToAnother = async (
from: ProvisioningStateValues,
to: ProvisioningStateValues,
dispatch: ThunkDispatch<IAppState, undefined, AnyAction>
) => {
const POLL_INTERVAL = 3000;
const _pollServiceState = async () => {
dispatch(requestPollingBotService());
const response = await getBotServicePromise();
dispatch(requestPollingBotServiceFinished({ payload: response }));
const isError: boolean = response instanceof ApiError;
if (isError) {
/*
TODO: Question:
if we have an error when the app starts polling,
should we keep polling or just return?
*/
return setTimeout(_pollServiceState, POLL_INTERVAL);
}
const botServiceResponse = response as Resource<BotService>;
const botService = botServiceResponse.resource;
switch (botService.infrastructure.provisioningDetails.state.id) {
case from:
// keep polling
return setTimeout(_pollServiceState, POLL_INTERVAL);
case to:
// done
return;
default:
//TODO: Dispatch custom error;
dispatch(
pollingBotTransitionError(
`The provisioning state has changed unexpectedly. Current state: ${botService.infrastructure.provisioningDetails.state.name}`
)
);
return;
}
};
// start polling
await _pollServiceState();
};

Просмотреть файл

@ -0,0 +1,45 @@
import { BotService } from "../../models/service/types";
import { Resource } from "../../services/api";
import baseReducer from "../base/BaseReducer";
import * as BotServiceActions from "./actions";
export interface BotServiceAppState {
botServices: BotService[];
loading: boolean;
}
export const INITIAL_STATE: BotServiceAppState = {
botServices: [],
loading: true,
};
export const serviceReducer = baseReducer(INITIAL_STATE, {
[BotServiceActions.REQUEST_BOT_SERVICE_FINISHED](state: BotServiceAppState, action: BotServiceActions.RequestBotServiceFinished){
const botService = action.payload! as Resource<BotService>;
return {
...state,
botServices: [botService.resource],
};
},
[BotServiceActions.REQUEST_POLLING_BOT_SERVICE_FINISHED](state: BotServiceAppState, action: BotServiceActions.RequestPollingBotServiceFinished){
const botService = action.payload! as Resource<BotService>;
return {
...state,
botServices: [botService.resource],
};
},
[BotServiceActions.REQUEST_START_SERVICE_FINISHED](state: BotServiceAppState, action: BotServiceActions.RequestStartService){
const botService = action.payload! as Resource<BotService>;
return {
...state,
botServices: [botService.resource],
};
},
[BotServiceActions.REQUEST_STOP_SERVICE_FINISHED](state: BotServiceAppState, action: BotServiceActions.RequestStopServiceFinished){
const botService = action.payload! as Resource<BotService>;
return {
...state,
botServices: [botService.resource],
};
}
})

Просмотреть файл

@ -0,0 +1,20 @@
import { v4 as uuid4 } from 'uuid';
import BaseAction from '../base/BaseAction';
import { IToastItem } from './reducer';
export const ADD_TOAST = 'ADD_TOAST';
export const REMOVE_TOAST = 'REMOVE_TOAST';
export interface AddToastMessage extends BaseAction<IToastItem> { }
export interface RemoveToastMessage extends BaseAction<string> { }
export const addToast = (message: string, type: string): AddToastMessage => ({
type: ADD_TOAST,
payload: { id: uuid4(), message, type }
});
export const removeToastById = (toastId: string): RemoveToastMessage => ({
type: REMOVE_TOAST,
payload: toastId
});

Просмотреть файл

@ -0,0 +1,34 @@
import { Reducer } from 'redux';
import * as ToastsAction from './actions';
import baseReducer from '../base/BaseReducer';
export interface IToastItem {
id?: string;
type: string;
message: string;
}
export interface IToastState{
items: IToastItem[];
}
const INITIAL_STATE: IToastState = {
items: [],
};
export const toastReducer: Reducer = baseReducer(INITIAL_STATE, {
[ToastsAction.ADD_TOAST](state: IToastState, action: ToastsAction.AddToastMessage): IToastState {
return {
...state,
items: [...state.items, action.payload!],
};
},
[ToastsAction.REMOVE_TOAST](state: IToastState, action: ToastsAction.RemoveToastMessage) {
const toastId = action.payload;
return {
...state,
items: state.items.filter((model) => model.id !== toastId),
};
}
});

Просмотреть файл

@ -0,0 +1,20 @@
import React, { Fragment } from 'react';
import CallInfo from './components/CallInfo';
import CallStreams from './components/CallStreams';
import NewInjectionStreamDrawer from './components/NewInjectionStreamDrawer';
import NewStreamDrawer from './components/NewStreamDrawer';
const CallDetails: React.FC<{}> = () => {
return (
<Fragment>
<NewStreamDrawer />
<NewInjectionStreamDrawer />
<div id="call">
<CallInfo />
<CallStreams />
</div>
</Fragment>
);
};
export default CallDetails;

Просмотреть файл

@ -0,0 +1,58 @@
#CallInfo {
min-height: 140px;
display: grid;
grid-template-columns: 250px 1fr auto;
justify-items: center;
align-items: flex-start;
padding: 0 !important;
}
#CallInfoSettings {
width: 100%;
display: grid;
grid-template-rows: 3fr 1fr;
justify-items: center;
align-items: center;
}
.CallSetting {
width: 200px;
}
#CallInfoProperties {
width: 100%;
height: 220px;
margin: 20px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
.CallInfoProperty {
width: 50%;
display: flex;
vertical-align: middle;
padding: 5px 0;
}
.CallInfoStatusBadge {
margin-left: 10px;
}
#CallInfoForm {
align-self: flex-end;
width: 50%;
max-width: 600px;
}
#CallInfoForm h4 {
padding-bottom: 10px;
}
#CallInfoOptions {
padding: 20px;
}
#CallInfoOptions button {
margin-bottom: 10px;
}

Просмотреть файл

@ -0,0 +1,287 @@
import React, { useEffect, useState, createRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import moment from 'moment';
import { Radio, Button, Badge, Typography, Form, Input, InputNumber, Popconfirm } from 'antd';
import { Rule, FormInstance } from 'antd/lib/form';
import EditIcon from '@material-ui/icons/Edit';
import SaveIcon from '@material-ui/icons/Save';
import CloseIcon from '@material-ui/icons/Close';
import IAppState from '../../../services/store/IAppState';
import CallSelector from './CallSelector';
import './CallInfo.css';
import { selectCallInfoProps } from '../../../stores/calls/selectors';
import { disconnectCallAsync } from '../../../stores/calls/asyncActions';
import {
CallDefaults,
CallState,
CallType,
StreamProtocol,
StreamState,
StreamType,
} from '../../../models/calls/types';
import { updateCallDefaults } from '../../../stores/calls/actions';
import { CallInfoProps } from '../types';
const { Text } = Typography;
const { Item } = Form;
const CallInfo: React.FC = () => {
const dispatch = useDispatch();
const { id: callId } = useParams<{ id: string }>();
const callInfoProps: CallInfoProps = useSelector((state: IAppState) => selectCallInfoProps(state, callId));
// disconnect
const disconnect = () => {
console.log('disconnect');
dispatch(disconnectCallAsync(callId));
};
// update settings
const onDefaultsUpdated = (newDefaults: unknown) => {
// invoke an asyncAction that will update on API
console.log('newDefaults', newDefaults);
dispatch(
updateCallDefaults({
callId,
defaults: newDefaults as CallDefaults,
})
);
toggleEditMode();
};
// edit state
const [editingDefaults, setEditingDefaults] = useState(false);
const [editingProtocol, setEditingProtocol] = useState(callInfoProps.call?.defaultProtocol ?? StreamProtocol.SRT);
const toggleEditMode = () => setEditingDefaults(!editingDefaults);
const formRef = createRef<FormInstance>();
useEffect(() => {
if (callInfoProps.call)
formRef.current?.setFieldsValue({
protocol: callInfoProps.call.defaultProtocol,
latency: callInfoProps.call.defaultLatency,
passphrase: callInfoProps.call.defaultPassphrase,
});
}, [callInfoProps.call?.id, editingDefaults]);
// no call, nothing to see
if (!callInfoProps.call) {
return null;
}
// time formating
const creationDate = moment(callInfoProps.call.createdAt);
const datePart = creationDate.format('L'); // 07/03/2020
const timePart = creationDate.format('LTS'); // 5:29:19 PM
const formattedCreationDate = datePart + ' ' + timePart;
const connected = callInfoProps.call.state === CallState.Established;
const participantsLength = callInfoProps.call.streams.filter(
(stream) => stream.type === StreamType.Participant
).length;
const activeStreams = callInfoProps.call.streams.filter((stream) =>
[StreamState.Stopping, StreamState.Starting, StreamState.Started].includes(stream.state)
).length;
const protocols = Object.keys(StreamProtocol).filter((i) => !isNaN(parseInt(i)));
const baseRules = {
protocol: [{ type: 'string', required: true } as Rule],
latency: [],
passphrase: [],
};
const rules =
editingProtocol === StreamProtocol.RTMP
? baseRules
: {
...baseRules,
latency: [{ type: 'integer', required: true } as Rule],
passphrase: [{ type: 'string' } as Rule],
};
const hasActiveStreams = callInfoProps.streams.find(
(o) => o.state === StreamState.Started || o.state === StreamState.Stopping || o.state === StreamState.Starting
)
? true
: false;
return (
<Form onFinish={onDefaultsUpdated} ref={formRef}>
<div id="CallInfo" className="PageBody">
<div id="CallInfoSettings">
<CallSelector />
<Popconfirm
placement="right"
title="Proceed to disconnect the bot from this call?"
onConfirm={disconnect}
disabled={!connected}
>
<Button className="CallSetting" type="primary" danger={true} disabled={!connected}>
Disconnect Call
</Button>
</Popconfirm>
</div>
<div id="CallInfoProperties">
<span className="CallInfoProperty">
Status:
<strong>
{renderStatusBadge(callInfoProps.call.state)}
{CallState[callInfoProps.call.state]}
</strong>
</span>
{connected && (
<>
<span className="CallInfoProperty">
<strong>
{activeStreams} active stream{activeStreams > 1 ? 's' : null}
</strong>
</span>
<span className="CallInfoProperty">
<br />
</span>
<span className="CallInfoProperty">
<Text copyable={{ text: callInfoProps.call.joinUrl }}>Invite Link</Text>
</span>
<span className="CallInfoProperty">
Call type:&nbsp;
<strong>
{CallType[callInfoProps.call.meetingType]} - ({participantsLength} participant
{participantsLength !== 1 ? 's' : null})
</strong>
</span>
<span className="CallInfoProperty">
Created:&nbsp;<strong>{formattedCreationDate}</strong>
</span>
{/* Defaults - READ Mode */}
{connected && !editingDefaults && (
<div id="CallInfoForm">
<span className="CallInfoProperty">
Default protocol:&nbsp;
<strong>
<Text>{StreamProtocol[callInfoProps.call.defaultProtocol]}</Text>
</strong>
</span>
{/* TODO: Switch options based on protocol */}
{callInfoProps.call.defaultProtocol === StreamProtocol.SRT && (
<>
<span className="CallInfoProperty">
Default latency:&nbsp;
<strong>
<Text>{callInfoProps.call.defaultLatency}ms</Text>
</strong>
</span>
{callInfoProps.call.defaultPassphrase && (
<span className="CallInfoProperty">
<Text copyable={{ text: callInfoProps.call.defaultPassphrase }}>Default passphrase</Text>
</span>
)}
</>
)}
{callInfoProps.call.botFqdn && (
<span className="CallInfoProperty">
Bot FQDN:&nbsp;
<strong>
<Text copyable>{callInfoProps.call.botFqdn}</Text>
</strong>
</span>
)}
{callInfoProps.call.botIp && (
<span className="CallInfoProperty">
Bot IP:&nbsp;
<strong>
<Text copyable>{callInfoProps.call.botIp}</Text>
</strong>
</span>
)}
</div>
)}
{/* Defaults - EDIT Mode */}
{connected && editingDefaults && (
<div id="CallInfoForm">
<strong>Defaults</strong>
<Item label="Protocol:" name="protocol" labelAlign="left" hasFeedback>
<Radio.Group value={editingProtocol} onChange={(e) => setEditingProtocol(e.target.value)}>
{protocols.map((p, index) => (
<Radio.Button key={p} value={parseInt(p)}>
{StreamProtocol[index]}
</Radio.Button>
))}
</Radio.Group>
</Item>
{/* Based on selected Protocol */}
{
{
[StreamProtocol.SRT]: (
<>
<Item label="Latency:" name="latency" rules={rules.latency} labelAlign="left" hasFeedback>
<InputNumber min={1} max={2000} placeholder="(ms)" />
</Item>
<Item
label="Passphrase:"
name="passphrase"
rules={rules.passphrase}
labelAlign="left"
hasFeedback
>
<Input.Password />
</Item>
</>
),
[StreamProtocol.RTMP]: <div className="rtmpProtocol"></div>,
}[editingProtocol]
}
</div>
)}
</>
)}
</div>
<div id="CallInfoOptions">
{connected && !editingDefaults && (
<Button
type="primary"
shape="circle"
icon={<EditIcon />}
size="large"
onClick={toggleEditMode}
disabled={hasActiveStreams}
/>
)}
{connected && editingDefaults && (
<>
<Button type="primary" shape="circle" icon={<SaveIcon />} size="large" htmlType="submit" />
<br />
<Button type="default" shape="circle" icon={<CloseIcon />} size="large" onClick={toggleEditMode} />
</>
)}
</div>
</div>
</Form>
);
};
export enum CallStateBadge {
yellow = CallState.Establishing,
green = CallState.Established,
orange = CallState.Terminating,
gray = CallState.Terminated,
}
const renderStatusBadge = (status: CallState): React.ReactElement => (
<Badge className="CallInfoStatusBadge" status="default" color={CallStateBadge[status]} />
);
export default CallInfo;

Просмотреть файл

@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter, Redirect } from 'react-router';
import { Select } from 'antd'
import IAppState from '../../../services/store/IAppState';
const { Option } = Select;
interface ICallSelectorDataProps extends RouteComponentProps<{ id: string }> {
selectedCallId?: string;
isNew: boolean;
calls: {
id: string,
name: string
}[]
}
type ICallSelectorProps = ICallSelectorDataProps;
const PLACEHOLDER_ID = '_';
const NEW_PLACEHOLDER_ID = '*';
const JOIN_CALL_ROUTE = '/call/join';
const CallSelector: React.FC<ICallSelectorProps> = (props) => {
// Handle redirect using <Redirect />
const [redirectId, setRedirectId] = useState<string>('');
if (redirectId && redirectId !== props.selectedCallId) {
if (redirectId === NEW_PLACEHOLDER_ID) {
return <Redirect to={JOIN_CALL_ROUTE} push={true} />
} else {
return <Redirect to={`/call/details/${redirectId}`} />
}
}
// When changing the <Select> value, trigger the redirect
const handleCallSelect = (callId: string) => {
if (callId === PLACEHOLDER_ID) {
return;
}
setRedirectId(callId);
}
const selectedId = props.isNew ? NEW_PLACEHOLDER_ID : (props.selectedCallId || PLACEHOLDER_ID);
return (
<Select defaultValue={selectedId} className="CallSetting" onChange={handleCallSelect}>
<Option value={PLACEHOLDER_ID}><i>(Select a call)</i></Option>
{props.calls.map(m => (<Option key={m.id} value={m.id}>{m.name || 'Teams TX Demo'}</Option>))}
<Option value={NEW_PLACEHOLDER_ID}><strong>(Join a new Call)</strong></Option>
</Select>
)
}
const mapStateToPros = (appState: IAppState, ownProps: RouteComponentProps<{ id: string }>): ICallSelectorDataProps => ({
...ownProps,
calls: appState.calls.activeCalls.map(o => ({
id: o.id,
name: o.displayName
})),
selectedCallId: ownProps.match.params.id,
isNew: ownProps.match.path === JOIN_CALL_ROUTE
})
export default withRouter(connect(mapStateToPros)(CallSelector));

Просмотреть файл

@ -0,0 +1,5 @@
#CallStreams h3 {
margin-top: 20px;
display: block;
clear: both;
}

Просмотреть файл

@ -0,0 +1,64 @@
import React from 'react';
import {useSelector } from 'react-redux';
import IAppState from '../../../services/store/IAppState';
import StreamCard from './StreamCard';
import './CallStreams.css';
import NewStreamPopUpDrawer from './NewStreamDrawer';
import InjectionCard from './InjectionCard';
import { useParams } from 'react-router-dom';
import { selectCallStreams } from '../../../stores/calls/selectors';
import { Stream } from '../../../models/calls/types';
import { CallStreamsProps } from '../types';
const CallStreams: React.FC = () => {
const { id: callId } = useParams<{id: string}>();
const callStreams = useSelector((state: IAppState) => selectCallStreams(state, callId));
if (!callStreams.mainStreams.length && !callStreams.participantStreams.length && !callStreams.activeStreams.length) {
// Empty Call?
return null;
}
const hasMainStreams = callStreams.mainStreams.length > 0;
const hasParticipants = callStreams.participantStreams.length > 0;
const hasActiveStreams = callStreams.activeStreams.length > 0;
return (
<div id="CallStreams">
<NewStreamPopUpDrawer />
<h3>Injection Stream</h3>
<InjectionCard callStreams={callStreams}></InjectionCard>
<h3>Active Streams</h3>
{(hasActiveStreams && renderStreams(callStreams.activeStreams, callStreams)) || (
<p>
<em>Start a stream from below</em>
</p>
)}
{hasMainStreams && (
<>
<h3>Main Streams</h3>
{renderStreams(callStreams.mainStreams, callStreams)}
</>
)}
{hasParticipants && (
<>
<h3>Participants</h3>
{renderStreams(callStreams.participantStreams, callStreams)}
</>
)}
<br className="break" />
</div>
);
};
const renderStreams = (streams: Stream[], callStreams: CallStreamsProps): React.ReactElement[] =>
streams.map((stream) => (
<StreamCard stream={stream} key={stream.id} callStreams={callStreams} callProtocol={callStreams.callProtocol} />
));
export default CallStreams;

Просмотреть файл

@ -0,0 +1,173 @@
.injectionCard {
float: left;
min-width: 400px;
position: relative;
}
@media only screen and (max-width: 1300px) {
.injectionCard {
min-width: 460px;
width: 50%;
}
}
@media only screen and (min-width: 1300px) {
.injectionCard {
min-width: auto;
width: 33%;
}
}
@media only screen and (min-width: 2000px) {
.injectionCard {
min-width: auto;
width: 25%;
}
}
.injectionCard .injectionCardContent {
height: 160px;
margin: 10px;
border: 3px solid;
border-radius: 77px 24px 24px 77px;
background-color: #fff;
transition: all 0.1s ease-in-out;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
flex-wrap: nowrap;
overflow: hidden;
}
.injectionCard .toggler {
transition: all 0.1s ease-in-out;
height: 160px;
}
.injectionCard.expanded .injectionCardContent {
height: 340px;
border-radius: 77px 24px 24px 24px;
}
.injectionCard.expanded .toggler {
height: 340px;
}
/* content */
.injectionCard .injectionCardContent > .ant-row > .ant-col {
padding: 20px;
}
.injectionCard .injectionCardContent > .ant-row > .ant-col.injectionMain {
padding-left: 0;
}
/* Avatar */
.injectionCard .ant-avatar-string {
font-size: 1.8em;
}
/* Display name */
.injectionCard h4 {
margin: 0;
padding: 0;
font-size: 1.4em;
}
/* Status */
.injectionCard .InjectionState {
font-size: 0.9em;
text-transform: uppercase;
font-weight: bold;
}
/* icons & btns */
.injectionCard .injectionActions {
height: 60px;
}
.injectionCard .injectionActions .anticon {
font-size: 18px;
margin-right: 4px;
}
/* main info */
.injectionCard .injectionMain {
flex-grow: 1;
}
.injectionCard .injectionDetails p {
padding: 0;
margin: 0 0 4px 0;
}
.injectionCard .injectionOptions {
text-align: right;
padding-right: 15px;
}
/* more details toggler */
.injectionCard .toggler {
position: absolute;
right: 10px;
top: 10px;
width: 28px;
border-radius: 0px 21px 21px 0px;
cursor: pointer;
}
.injectionCard .toggler span {
color: white;
font-weight: bold;
display: block;
position: absolute;
transform: rotate(-90deg);
width: 150px;
bottom: 60px;
right: -60px;
text-align: center;
white-space: nowrap;
}
/* palette - disconnected / non-injectioning */
.injectionCard .injectionCardContent {
border-color: #bdbdbd;
}
.injectionCard .toggler {
background-color: #bdbdbd;
}
.injectionCard .injectionCardContent .InjectionState {
color: #bdbdbd;
}
/* palette - establised */
.injectionCard.established .injectionCardContent {
border-color: #73c856;
}
.injectionCard.established .toggler {
background-color: #73c856;
}
.injectionCard.established .injectionCardContent .InjectionState {
color: #73c856;
}
/* palette - initializing/connecting */
.injectionCard.initializing .injectionCardContent {
border-color: #f3ca3e;
}
.injectionCard.initializing .toggler {
background-color: #f3ca3e;
}
.injectionCard.initializing .injectionCardContent .InjectionState {
color: #f3ca3e;
}
/* palette - error/unhealthy */
.injectionCard.error .injectionCardContent {
border-color: #df3639;
}
.injectionCard.error .toggler {
background-color: #df3639;
}
.injectionCard.error .injectionCardContent .InjectionState {
color: #df3639;
}

Просмотреть файл

@ -0,0 +1,221 @@
import { IconButton } from '@material-ui/core';
import { Mic, MicOff } from '@material-ui/icons';
import { Avatar, Button, Col, Row, Typography } from 'antd';
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { InjectionStream, StreamMode, StreamProtocol, StreamState } from '../../../models/calls/types';
import { openNewInjectionStreamDrawer } from '../../../stores/calls/actions';
import { muteBotAsync, stopInjectionAsync, unmuteBotAsync } from '../../../stores/calls/asyncActions';
import { CallStreamsProps } from '../types';
import './InjectionCard.css';
interface InjectionCardProps {
callStreams: CallStreamsProps;
}
const avatarSize = 112;
const OBFUSCATION_PATTERN = "********";
const InjectionCard: React.FC<InjectionCardProps> = (props) => {
const dispatch = useDispatch();
const { callStreams } = props;
const callId = callStreams.callId;
const stream = callStreams.injectionStream;
const streamId = stream?.id;
const hasStream = callStreams.injectionStream && stream?.state !== StreamState.Disconnected;
const startInjection = () => {
if (callId) {
dispatch(openNewInjectionStreamDrawer({
callId
}));
}
};
const stopInjection = () => {
if (callId && streamId) {
dispatch(stopInjectionAsync(
callId,
streamId,
))
}
};
const [audioMuted, setAudioMuted] = useState(false);
const [expanded, setExpanded] = useState(false);
const toggleExpand = () => setExpanded(!expanded);
// collapse disabled streams
if (expanded && !stream) {
setExpanded(false);
}
const classes = ['injectionCard', getConnectionClass(stream), expanded ? 'expanded' : ''];
const status = getConnectionStatus(stream);
const injectionUrl = stream ? getInjectionUrl(stream) : "";
const protocolText = () => {
switch (stream?.protocol) {
case StreamProtocol.RTMP:
return 'RTMP';
case StreamProtocol.SRT:
return 'SRT';
default:
return '';
}
};
const streamModeText = () => {
switch (stream?.streamMode) {
case StreamMode.Caller:
return stream?.protocol === StreamProtocol.RTMP ? 'Pull' : 'Caller';
case StreamMode.Listener:
return stream?.protocol === StreamProtocol.RTMP ? 'Push' : 'Listener';
default:
return '';
}
};
const toggleBotAudio = () => {
console.log('muted');
if (callId) {
if (!audioMuted) {
dispatch(muteBotAsync(callId));
setAudioMuted(true);
} else {
dispatch(unmuteBotAsync(callId))
setAudioMuted(false);
}
}
};
return (
<div className={classes.join(' ')}>
<div className="injectionCardContent">
<Row>
<Col>
<Avatar size={avatarSize} icon="IS"></Avatar>
</Col>
<Col className="injectionMain">
<h4>Injection Stream</h4>
<span className="InjectionState">{status}</span>
<Row justify="space-between" align="bottom" gutter={0} className="injectionActions">
<Col span={12}>
<IconButton onClick={toggleBotAudio}>{audioMuted ? <MicOff /> : <Mic />}</IconButton>
</Col>
<Col span={12} className="injectionOptions">
<Button
type="primary"
shape="round"
onClick={stream == null ? startInjection : stopInjection}
disabled={
!callStreams.callEnabled || stream?.state == StreamState.Starting
}
>
{stream == null ? 'START' : 'STOP'}
</Button>
</Col>
</Row>
</Col>
</Row>
<Row>
<Col className="streamDetails">
{stream && (
<>
<div>
Injection URL:
<strong>
<Typography.Text copyable={{ text: stream?.injectionUrl }}>{'' + injectionUrl}</Typography.Text>
</strong>
</div>
{stream.protocol === StreamProtocol.SRT && (
<>
<div>
Passphrase:
{
<strong>
<Typography.Text> {stream.passphrase == null ? 'None' : ''}</Typography.Text>
</strong>
}
{stream.passphrase && (
<strong>
<Typography.Text copyable={{ text: stream.passphrase }}>{'********'}</Typography.Text>
</strong>
)}
</div>
<div>
Latency: <strong>{stream.latency}ms</strong>
</div>
</>
)}
<div>
Protocol: <strong>{protocolText()}</strong>
</div>
<div>
Stream Mode: <strong>{streamModeText()}</strong>
</div>
</>
)}
</Col>
</Row>
</div>
{hasStream && (
<div className="toggler" onClick={toggleExpand}>
<span>{expanded ? '- less info' : '+ more info'}</span>
</div>
)}
</div>
);
};
const getConnectionClass = (stream: InjectionStream | null): string => {
switch (stream?.state) {
case StreamState.Stopping:
return 'disconnected';
case StreamState.Disconnected:
return 'disconnected';
case StreamState.Starting:
return 'initializing';
case StreamState.Started:
return 'established';
case StreamState.Error:
case StreamState.StartingError:
case StreamState.StoppingError:
return 'error';
default:
return '';
}
};
const getConnectionStatus = (stream: InjectionStream | null): string => {
switch (stream?.state) {
case StreamState.Disconnected:
return 'Available Stream';
case StreamState.Stopping:
return 'Stopping';
case StreamState.Starting:
return 'Starting';
case StreamState.Error:
case StreamState.StartingError:
case StreamState.StoppingError:
return 'Unhealthy Stream';
case StreamState.Started:
return 'Active Stream';
default:
return 'Available Stream';
}
};
const getInjectionUrl = (stream: InjectionStream): string => {
if (stream.protocol === StreamProtocol.RTMP && stream.injectionUrl) {
let rtmpUrl = stream.injectionUrl.replace(
stream.passphrase,
OBFUSCATION_PATTERN
);
return rtmpUrl;
}
return stream.injectionUrl ?? "";
};
export default InjectionCard;

Просмотреть файл

@ -0,0 +1,61 @@
#NewStreamDrawerBody {
height: 100%;
}
#NewStreamDrawerFooter{
height: 100%;
width: 95%;
display: grid;
grid-template-columns: 1fr;
justify-items: flex-start;
align-items: center;
}
#NewStreamDrawerFooterInner{
height: 100%;
width: 60%;
display: grid;
justify-items: center;
grid-template-columns: 1fr 1fr;
}
#cancelButton {
margin-left: 5%;
}
.chooseAFlowText {
font-size: 1.2em;
}
.insertUrlText {
font-size: 1.1em;
}
#ParticipantsListContainer{
width: 90%;
margin-left: 10%;
height: 100%;
}
.selectedFlowText{
font-weight: bold;
font-size: 1.1em;
}
.NewStreamSettingBox{
margin-bottom: 2em;
}
.NewStreamSettingText{
padding-bottom: 0.5em;
display: block;
}
.DrawerButton{
width: 120px;
}
.NewStreamInput{
width: 300px;
}
.settingsText{
width: 90%;
}

Просмотреть файл

@ -0,0 +1,243 @@
import React, { ReactText, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Drawer, Button, Input, Radio, InputNumber, Tooltip, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import IAppState from '../../../services/store/IAppState';
import './NewInjectionStreamDrawer.css';
import Form from 'antd/lib/form';
import { Switch } from 'antd';
import { Call, NewInjectionStream, StreamMode, StreamProtocol } from '../../../models/calls/types';
import { selectNewInjectionStreamDrawerProps } from '../../../stores/calls/selectors';
import { closeNewInjectionStreamDrawer } from '../../../stores/calls/actions';
import { startInjectionAsync, refreshStreamKeyAsync } from '../../../stores/calls/asyncActions';
interface DrawerState {
protocol?: StreamProtocol;
injectionUrl?: string;
streamMode?: StreamMode;
latency?: number;
passphrase?: string;
enableSsl?: boolean;
}
const DEFAULT_LATENCY = 750;
const OBFUSCATION_PATTERN = '********';
const NewInjectionStreamDrawer: React.FC = () => {
const dispatch = useDispatch();
const { id: callId } = useParams<{ id: string }>();
const drawerProps = useSelector((state: IAppState) => selectNewInjectionStreamDrawerProps(state, callId));
const visible = !!drawerProps.newInjectionStream;
//Drawer's state
const initialState: DrawerState = {
protocol: drawerProps.newInjectionStream?.protocol || StreamProtocol.SRT,
streamMode: drawerProps.newInjectionStream?.mode || StreamMode.Caller,
injectionUrl: drawerProps.newInjectionStream?.streamUrl,
latency: drawerProps.newInjectionStream?.latency || DEFAULT_LATENCY,
passphrase: drawerProps.newInjectionStream?.streamKey,
enableSsl: drawerProps.newInjectionStream?.enableSsl,
};
//Warning! It wasn't tested with nested objects
const [state, setState] = useReducer(
(state: DrawerState, newState: Partial<DrawerState>) => ({ ...state, ...newState }),
{}
);
const loadDefaultSettings = () => {
const protocol = drawerProps.newInjectionStream?.protocol || StreamProtocol.SRT;
const streamMode = drawerProps.newInjectionStream?.mode || StreamMode.Caller;
const injectionUrl = drawerProps.newInjectionStream?.streamUrl;
const latency = drawerProps.newInjectionStream?.latency || DEFAULT_LATENCY;
const passphrase = drawerProps.newInjectionStream?.streamKey;
const enableSsl = drawerProps.newInjectionStream?.enableSsl;
setState({ protocol, streamMode, injectionUrl, latency, passphrase, enableSsl });
};
const rtmpPushStreamKey = drawerProps.call?.privateContext?.streamKey ?? '';
const rtmpPushStreamUrl = getRtmpPushStreamUrl(drawerProps.call!, !!state.enableSsl);
const handleChange = (e: any) => {
setState({ [e.target.name]: e.target.value });
};
const handleSwitchChange = (checked: boolean) => {
setState({ enableSsl: checked });
};
const handleLatencyChange = (value?: ReactText) => {
const latency = parseInt(value?.toString() ?? '0', 10);
setState({ latency });
};
const handleRefreshStremKey = () => {
dispatch(refreshStreamKeyAsync(callId));
};
const handleClose = () => {
dispatch(closeNewInjectionStreamDrawer());
};
const handleSave = () => {
if (!drawerProps.newInjectionStream) {
return;
}
const newInjectionStream: NewInjectionStream = {
callId: drawerProps.newInjectionStream.callId,
protocol: state.protocol || StreamProtocol.SRT,
mode: state.streamMode || StreamMode.Caller,
streamUrl: state.injectionUrl,
latency: state.latency,
streamKey: state.passphrase,
enableSsl: state.enableSsl,
};
console.log("New injection stream request", newInjectionStream);
dispatch(startInjectionAsync(newInjectionStream));
};
return (
<Drawer
destroyOnClose={true}
title="Add a new stream"
visible={visible}
afterVisibleChange={loadDefaultSettings}
width={'30%'}
bodyStyle={{ height: 400 }}
onClose={handleClose}
footer={
<div id="NewInjectionStreamDrawerFooter">
<div id="NewInjectionStreamDrawerFooterInner">
<Button className="DrawerButton" type="primary" form="injectionForm" htmlType="submit">
Start
</Button>
<Button onClick={handleClose} className="DrawerButton" type="default">
Cancel
</Button>
</div>
</div>
}
>
<div id="NewStreamDrawerBody">
<Form name="injectionForm" onFinish={handleSave} layout="vertical" initialValues={initialState}>
<div className="NewStreamSettingBox">
<span className="selectedFlowText">Start injection</span>
</div>
<Form.Item label="Protocol" name="protocol">
<Radio.Group name="protocol" value={state.protocol} onChange={handleChange}>
<Radio.Button value={StreamProtocol.SRT}>SRT</Radio.Button>
<Radio.Button value={StreamProtocol.RTMP}>RTMP</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="Mode" name="streamMode">
<Radio.Group name="streamMode" value={state.streamMode} onChange={handleChange}>
<Radio.Button value={StreamMode.Caller}>
{state.protocol === StreamProtocol.SRT ? 'Caller' : 'Pull'}
</Radio.Button>
<Radio.Button value={StreamMode.Listener}>
{state.protocol === StreamProtocol.SRT ? 'Listener' : 'Push'}
</Radio.Button>
</Radio.Group>
</Form.Item>
{state.streamMode === StreamMode.Caller && (
<Form.Item
label="Injection URL"
name="injectionUrl"
rules={[
{
required: true,
whitespace: false,
message: 'Please add Injection URL',
},
]}
>
<Input
className="NewStreamInput"
name="injectionUrl"
value={state.injectionUrl}
onChange={handleChange}
/>
</Form.Item>
)}
{state.protocol === StreamProtocol.SRT && (
<>
<Form.Item label="Latency" name="latency">
<InputNumber
className="NewStreamInput"
min={0}
name="latency"
value={state.latency}
onChange={handleLatencyChange}
/>
</Form.Item>
<Form.Item label="Passphrase" name="passphrase">
<Input.Password
className="NewStreamInput"
name="passphrase"
value={state.passphrase}
onChange={handleChange}
/>
</Form.Item>
</>
)}
{state.protocol === StreamProtocol.RTMP && state.streamMode === StreamMode.Listener && (
<>
<Form.Item label="Enable Ssl">
<Switch onChange={handleSwitchChange} />
</Form.Item>
<Form.Item label="Stream key">
<Input.Password className="NewStreamInput" value={rtmpPushStreamKey} contentEditable={false} />
<Tooltip title="Refresh Stream Key">
<Button
type="primary"
shape="circle"
icon={<ReloadOutlined />}
style={{ marginLeft: 10 }}
onClick={handleRefreshStremKey}
></Button>
</Tooltip>
</Form.Item>
<Form.Item label="Stream URL">
<Typography.Text copyable={{ text: rtmpPushStreamUrl.replace(OBFUSCATION_PATTERN, rtmpPushStreamKey) }}>
<strong>{'' + rtmpPushStreamUrl}</strong>
</Typography.Text>
</Form.Item>
</>
)}
</Form>
</div>
</Drawer>
);
};
const getRtmpPushStreamUrl = (call: Call, enableSsl: boolean): string => {
let protocol = 'rtmp';
let port = 1936;
if (enableSsl) {
protocol = 'rtmps';
port = 2936;
}
if (call) {
const domain = call.botFqdn?.split(':')[0];
return `${protocol}://${domain}:${port}/${OBFUSCATION_PATTERN}?callId=${call?.id}`;
}
return "";
};
export default NewInjectionStreamDrawer;

Просмотреть файл

@ -0,0 +1,69 @@
#NewStreamDrawerBody {
height: 100%;
}
#NewStreamDrawerFooter{
height: 100%;
width: 95%;
display: grid;
grid-template-columns: 1fr;
justify-items: flex-start;
align-items: center;
}
#NewStreamDrawerFooterInner{
height: 100%;
width: 60%;
display: grid;
justify-items: center;
grid-template-columns: 1fr 1fr;
}
#cancelButton {
margin-left: 5%;
}
.chooseAFlowText {
font-size: 1.2em;
}
.insertUrlText {
font-size: 1.1em;
}
#ParticipantsListContainer{
width: 90%;
margin-left: 10%;
height: 100%;
}
.selectedFlowText{
font-weight: bold;
font-size: 1.1em;
}
.NewStreamSettingBox{
margin-bottom: 2em;
}
.NewStreamSettingControl{
margin-bottom: 0.5em;
}
.NewStreamSettingText{
padding-bottom: 0.5em;
display: block;
}
.NewStreamSettingTopLabel{
display: block;
}
.NewStreamSettingInlineLabel{
margin-right: 0.5em;
}
.DrawerButton{
width: 120px;
}
.NewStreamInput{
width: 300px;
}
.settingsText{
width: 90%;
}

Просмотреть файл

@ -0,0 +1,313 @@
import React, { ReactText, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Drawer, Button, Input, Radio, InputNumber, Alert, Switch, Select } from 'antd';
import IAppState from '../../../services/store/IAppState';
import './NewStreamDrawer.css';
import { selectNewStreamDrawerProps } from '../../../stores/calls/selectors';
import { useParams } from 'react-router-dom';
import { StartStreamRequest, StreamConfiguration, StreamMode, StreamProtocol, StreamSrtConfiguration, StreamType } from '../../../models/calls/types';
import { closeNewStreamDrawer } from '../../../stores/calls/actions';
import { startStreamAsync } from '../../../stores/calls/asyncActions';
enum ViewMode {
Simple,
Advanced,
}
interface DrawerState {
protocol?: StreamProtocol;
flow?: StreamType;
url?: string;
mode?: StreamMode;
port?: string;
passphrase?: string;
latency?: number;
followSpeakerAudio?: boolean;
showAdvanced?: boolean;
viewMode?: ViewMode;
unmixedAudio?: boolean;
audioFormat?: number;
timeOverlay?: boolean;
}
const NewStreamDrawer: React.FC = () => {
const dispatch = useDispatch();
const { id: callId } = useParams<{ id: string }>();
const drawerProps = useSelector((state: IAppState) => selectNewStreamDrawerProps(state, callId));
const visible = !!drawerProps.newStream;
//Warning! It wasn't tested with nested objects
const [state, setState] = useReducer(
(state: DrawerState, newState: Partial<DrawerState>) => ({ ...state, ...newState }),
{ viewMode: ViewMode.Simple,}
);
const loadDefaultSettings = () => {
const protocol = drawerProps.call?.defaultProtocol || StreamProtocol.SRT;
const passphrase = protocol === StreamProtocol.SRT ? drawerProps.newStream?.advancedSettings.key : '';
const latency = drawerProps.newStream?.advancedSettings.latency;
const url = '';
const mode = StreamMode.Listener;
const unmixedAudio = drawerProps.newStream?.advancedSettings.unmixedAudio;
const audioFormat = 0;
const timeOverlay = true;
setState({ protocol, passphrase, latency, url, mode, unmixedAudio, audioFormat, timeOverlay });
};
const handleChange = (e: any) => {
setState({ [e.target.name]: e.target.value });
};
const handleLatencyChange = (value?: ReactText) => {
const latency = parseInt(value?.toString() ?? '0', 10);
setState({ latency: latency });
};
const handleSwitch = (checked: boolean) => {
setState({ followSpeakerAudio: checked });
};
const handleClose = () => {
dispatch(closeNewStreamDrawer());
};
const handleSave = () => {
if (!drawerProps.newStream) {
return;
}
const config = getStreamConfiguration(state) as StreamConfiguration;
const newStream: StartStreamRequest = {
callId: drawerProps.newStream.callId,
type: drawerProps.newStream.streamType,
participantId: drawerProps.newStream.participantId,
protocol: state.protocol || StreamProtocol.SRT,
config,
};
dispatch(startStreamAsync(newStream));
};
const handleUnmixedAudioChange = (e: any) => {
setState({ unmixedAudio: e.target.value });
};
const handleAudioFormatChange = (value: any) => {
setState({ audioFormat: value });
};
const handleTimeOverlayChange = (checked: boolean) => {
setState({ timeOverlay: checked });
};
const getStreamConfiguration = (state: DrawerState) => {
switch (state.protocol) {
case StreamProtocol.SRT:
return {
mode: state.mode,
latency: state.latency,
streamKey: state.passphrase,
streamUrl: state.url,
unmixedAudio: state.unmixedAudio,
audioFormat: state.audioFormat,
timeOverlay: state.timeOverlay,
} as StreamSrtConfiguration;
case StreamProtocol.RTMP:
return {
unmixedAudio: state.unmixedAudio,
streamKey: state.passphrase,
streamUrl: state.url,
audioFormat: state.audioFormat,
timeOverlay: state.timeOverlay,
} as StreamConfiguration;
default:
return {};
}
};
const renderCommonSettings = () => {
const demuxedWarning = (
<span>
Forces to capture this participant&apos;s audio stream.
<br />
<strong>If the audio is not available at any moment, no audio will be streamed.</strong>
</span>
);
return (
<>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Audio settings</span>
<div className="NewStreamSettingControl">
<span className="NewStreamSettingTopLabel">Audio capture mode</span>
<Radio.Group value={state.unmixedAudio} onChange={handleUnmixedAudioChange}>
<Radio.Button value={false}>Mixed audio</Radio.Button>
<Radio.Button value={true}>Only Participant&apos;s audio</Radio.Button>
</Radio.Group>
{state.unmixedAudio ? (
<Alert style={{ marginTop: '5%' }} message={demuxedWarning} type="warning" showIcon />
) : null}
</div>
<div className="NewStreamSettingControl">
<span className="NewStreamSettingTopLabel">Audio format</span>
<Select value={state.audioFormat} onChange={handleAudioFormatChange}>
<Select.Option value={0}>AAC 44100Hz</Select.Option>
<Select.Option value={1}>AAC 48000Hz</Select.Option>
</Select>
</div>
</div>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Video settings</span>
<div className="NewStreamSettingControl">
<span className="NewStreamSettingInlineLabel">Add time overlay in the video:</span>
<Switch onChange={handleTimeOverlayChange} checked={state.timeOverlay} />
</div>
</div>
</>
);
};
return(
<Drawer
destroyOnClose={true}
title="Add a new stream"
visible={visible}
afterVisibleChange={loadDefaultSettings}
width={'30%'}
bodyStyle={{ height: 400 }}
onClose={handleClose}
footer={
<div id="NewStreamDrawerFooter">
<div id="NewStreamDrawerFooterInner">
<Button onClick={handleSave} className="DrawerButton" type="primary">
Start
</Button>
<Button onClick={handleClose} className="DrawerButton" type="default">
Cancel
</Button>
</div>
</div>
}
>
<div id="NewStreamDrawerBody">
<div>
<span className="selectedFlowText">Selected flow:</span>
<p>{`Following ${drawerProps.newStream?.participantName}`}</p>
</div>
{state.protocol === StreamProtocol.SRT && (
<>
<div className="NewStreamSettingBox">
<div>
<Radio.Group name="viewMode" value={state.viewMode} onChange={handleChange}>
<Radio.Button value={ViewMode.Simple}>Default settings</Radio.Button>
<Radio.Button value={ViewMode.Advanced}>Advanced settings</Radio.Button>
</Radio.Group>
</div>
</div>
{state.viewMode === ViewMode.Advanced ? (
<div>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Mode</span>
<div>
<Radio.Group name="mode" value={state.mode} onChange={handleChange}>
<Radio.Button value={StreamMode.Listener}>Listener</Radio.Button>
<Radio.Button value={StreamMode.Caller}>Caller</Radio.Button>
</Radio.Group>
</div>
</div>
{state.mode === StreamMode.Caller ? (
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Insert your SRT URL</span>
<div>
<Input
className="NewStreamInput"
placeholder="Stream url"
name="url"
value={state.url}
onChange={handleChange}
/>
</div>
</div>
) : null}
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Latency</span>
<div>
<InputNumber
className="NewStreamInput"
min={0}
defaultValue={0}
name="latency"
value={state.latency}
onChange={handleLatencyChange}
/>
</div>
</div>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Passphrase</span>
<div>
<Input.Password
className="NewStreamInput"
min={0}
defaultValue={0}
name="passphrase"
value={state.passphrase}
onChange={handleChange}
/>
</div>
</div>
{renderCommonSettings()}
</div>
) : (
<div>
<div className="NewStreamSettingBox settingsText">
<span className="NewStreamSettingText">
By pressing &quot;Start&quot; a new stream will be created with the default settings set for this
call. To edit them, switch to advanced.
</span>
</div>
</div>
)}
</>
)}
{state.protocol === StreamProtocol.RTMP && (
<>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Stream Url</span>
<div>
<Input className="NewStreamInput" name="url" value={state.url} onChange={handleChange} />
</div>
</div>
<div className="NewStreamSettingBox">
<span className="NewStreamSettingText">Stream Key</span>
<div>
<Input.Password
className="NewStreamInput"
min={0}
defaultValue={0}
name="passphrase"
value={state.passphrase}
onChange={handleChange}
/>
</div>
</div>
{renderCommonSettings()}
</>
)}
</div>
</Drawer>
)
}
export default NewStreamDrawer;

Просмотреть файл

@ -0,0 +1,173 @@
.streamCard {
float: left;
min-width: 400px;
position: relative;
}
@media only screen and (max-width: 1300px) {
.streamCard {
min-width: 460px;
width: 50%;
}
}
@media only screen and (min-width: 1300px) {
.streamCard {
min-width: auto;
width: 33%;
}
}
@media only screen and (min-width: 2000px) {
.streamCard {
min-width: auto;
width: 25%;
}
}
.streamCard .streamCardContent {
height: 160px;
margin: 10px;
border: 3px solid;
border-radius: 77px 24px 24px 77px;
background-color: #fff;
transition: all 0.1s ease-in-out;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
flex-wrap: nowrap;
overflow: hidden;
}
.streamCard .toggler {
transition: all 0.1s ease-in-out;
height: 160px;
}
.streamCard.expanded .streamCardContent {
height: 340px;
border-radius: 77px 24px 24px 24px;
}
.streamCard.expanded .toggler {
height: 340px;
}
/* content */
.streamCard .streamCardContent > .ant-row > .ant-col {
padding: 20px;
}
.streamCard .streamCardContent > .ant-row > .ant-col.streamMain {
padding-left: 0;
}
/* Avatar */
.streamCard .ant-avatar-string {
font-size: 1.8em;
}
/* Display name */
.streamCard h4 {
margin: 0;
padding: 0;
font-size: 1.4em;
}
/* Status */
.streamCard .StreamState {
font-size: 0.9em;
text-transform: uppercase;
font-weight: bold;
}
/* icons & btns */
.streamCard .streamActions {
height: 60px;
}
.streamCard .streamActions .anticon {
font-size: 18px;
margin-right: 4px;
}
/* main info */
.streamCard .streamMain {
flex-grow: 1;
}
.streamCard .streamDetails p {
padding: 0;
margin: 0 0 4px 0;
}
.streamCard .streamOptions {
text-align: right;
padding-right: 15px;
}
/* more details toggler */
.streamCard .toggler {
position: absolute;
right: 10px;
top: 10px;
width: 28px;
border-radius: 0px 21px 21px 0px;
cursor: pointer;
}
.streamCard .toggler span {
color: white;
font-weight: bold;
display: block;
position: absolute;
transform: rotate(-90deg);
width: 150px;
bottom: 60px;
right: -60px;
text-align: center;
white-space: nowrap;
}
/* palette - disconnected / non-streaming */
.streamCard .streamCardContent {
border-color: #bdbdbd;
}
.streamCard .toggler {
background-color: #bdbdbd;
}
.streamCard .streamCardContent .StreamState {
color: #bdbdbd;
}
/* palette - establised */
.streamCard.established .streamCardContent {
border-color: #73c856;
}
.streamCard.established .toggler {
background-color: #73c856;
}
.streamCard.established .streamCardContent .StreamState {
color: #73c856;
}
/* palette - initializing/connecting */
.streamCard.initializing .streamCardContent {
border-color: #f3ca3e;
}
.streamCard.initializing .toggler {
background-color: #f3ca3e;
}
.streamCard.initializing .streamCardContent .StreamState {
color: #f3ca3e;
}
/* palette - error/unhealthy */
.streamCard.error .streamCardContent {
border-color: #df3639;
}
.streamCard.error .toggler {
background-color: #df3639;
}
.streamCard.error .streamCardContent .StreamState {
color: #df3639;
}

Просмотреть файл

@ -0,0 +1,226 @@
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Avatar, Row, Col, Button, Typography } from 'antd';
import Videocam from '@material-ui/icons/Videocam';
import VideocamOff from '@material-ui/icons/VideocamOff';
import Mic from '@material-ui/icons/Mic';
import MicOff from '@material-ui/icons/MicOff';
import ScreenShare from '@material-ui/icons/ScreenShare';
import StopScreenShare from '@material-ui/icons/StopScreenShare';
import './StreamCard.css';
import { Stream, StreamProtocol, StreamState, StreamType } from '../../../models/calls/types';
import { openNewStreamDrawer } from '../../../stores/calls/actions';
import { stopStreamAsync } from '../../../stores/calls/asyncActions';
import { CallStreamsProps } from '../types';
import { ApiClient } from '../../../services/api';
import { ApiError } from '../../../models/error/types';
const { Text } = Typography;
interface StreamCardProps {
callProtocol: StreamProtocol;
stream: Stream;
callStreams: CallStreamsProps;
}
const StreamCard: React.FC<StreamCardProps> = (props) => {
const dispatch = useDispatch();
const { callProtocol: protocol, stream, callStreams } = props;
const [expanded, setExpanded] = useState(false);
const toggleExpand = () => setExpanded(!expanded);
const [avatartImage, setAvatartImage] = useState('');
useEffect(() => {
if (stream.photoUrl) {
ApiClient.get<any>({
url: stream.photoUrl,
isSecured: true,
shouldOverrideBaseUrl: true,
config: {
responseType: "blob",
},
}).then((response) => {
const isError = response instanceof ApiError;
if (isError) {
const error = response as ApiError;
console.error(error.raw);
return;
}
console.log({
response,
});
const urlCreator = window.URL || window.webkitURL;
const imageUrl = urlCreator.createObjectURL(response);
setAvatartImage(imageUrl);
});
}
}, []);
// collapse disabled streams
if (expanded && !callStreams.callEnabled) {
setExpanded(false);
}
const isStreamDisconnected = stream.state === StreamState.Disconnected;
const toggleStreamOperation = () => {
if (!isStreamDisconnected && expanded) {
// active & expanded, collapse
setExpanded(false);
}
if (isStreamDisconnected) {
dispatch(openNewStreamDrawer({
callId: callStreams.callId,
streamType: stream.type,
participantId: stream.id,
participantName: stream.displayName,
}))
}
if (!isStreamDisconnected) {
dispatch(stopStreamAsync({
callId: callStreams.callId,
type: stream.type,
participantId: stream.id,
participantName: stream.displayName,
}))
}
};
const initials = stream.displayName
.split(' ')
.map((s) => s[0].toUpperCase())
.join('');
const hasStream = callStreams.callEnabled && stream.state !== StreamState.Disconnected;
const status = getConnectionStatus(stream);
const operationEnabled =
callStreams.callEnabled &&
(stream.state === StreamState.Started ||
(stream.state === StreamState.Disconnected &&
((stream.type === StreamType.VbSS && callStreams.stageEnabled) ||
(stream.type === StreamType.PrimarySpeaker && callStreams.primarySpeakerEnabled) ||
(stream.type === StreamType.Participant && stream.isSharingVideo))));
const classes = ['streamCard', getConnectionClass(stream), expanded ? 'expanded' : ''];
const avatarSize = 112;
const avatarIcon = avatartImage ? (
<img src={avatartImage} style={{ width: avatarSize, height: avatarSize }} onError={() => setAvatartImage('')} />
) : (
<>{initials}</>
);
return (
<div className={classes.join(' ')}>
<div className="streamCardContent">
<Row>
<Col>
<Avatar icon={avatarIcon} size={avatarSize}></Avatar>
</Col>
<Col className="streamMain">
<h4>{props.stream.displayName}</h4>
<span className="StreamState">{status}</span>
<Row justify="space-between" align="bottom" gutter={0} className="streamActions">
{(stream.type === StreamType.Participant && (
<Col span={12}>
{stream.isSharingVideo && <Videocam />}
{!stream.isSharingVideo && <VideocamOff />}
{stream.isSharingAudio && !stream.audioMuted && <Mic />}
{stream.isSharingAudio && stream.audioMuted && <MicOff />}
{stream.isSharingScreen && <ScreenShare />}
{!stream.isSharingScreen && <StopScreenShare />}
</Col>
)) || <Col span={12}></Col>}
<Col span={12} className="streamOptions">
<Button type="primary" shape="round" onClick={toggleStreamOperation} disabled={!operationEnabled}>
{stream.state === StreamState.Disconnected ? 'START' : 'STOP'}
</Button>
</Col>
</Row>
</Col>
</Row>
<Row>
<Col className="streamDetails">
{stream.details && (
<>
<div>
Stream URL:{' '}
<strong>
<Typography.Text copyable>{stream.details.streamUrl}</Typography.Text>
</strong>
</div>
<div>
Audio Stream: <strong>{stream.details.audioDemuxed ? 'Demuxed' : 'Muxed'}</strong>
</div>
{protocol === StreamProtocol.RTMP ? (
<div>
StreamKey:{' '}
<strong>
<Typography.Text copyable>{stream.details.passphrase}</Typography.Text>
</strong>
</div>
) : (
<div>
<div>
Passphrase:{' '}
<strong>
<Typography.Text copyable>{stream.details.passphrase}</Typography.Text>
</strong>
</div>
<div>
Latency: <strong>{stream.details.latency}ms</strong>
</div>
</div>
)}
</>
)}
</Col>
</Row>
</div>
{hasStream && (
<div className="toggler" onClick={toggleExpand}>
<span>{expanded ? '- less info' : '+ more info'}</span>
</div>
)}
</div>
);
};
const getConnectionClass = (stream: Stream): string => {
switch (stream.state) {
case StreamState.Stopping:
return 'disconnected';
case StreamState.Disconnected:
return 'disconnected';
case StreamState.Starting:
return 'initializing';
case StreamState.Started:
return stream.isHealthy ? 'established' : 'error';
case StreamState.Error:
case StreamState.StartingError:
case StreamState.StoppingError:
return 'error';
}
};
const getConnectionStatus = (stream: Stream): string => {
switch (stream.state) {
case StreamState.Disconnected:
return 'Available Stream';
case StreamState.Stopping:
return 'Stopping';
case StreamState.Starting:
return 'Starting';
case StreamState.Error:
case StreamState.StartingError:
case StreamState.StoppingError:
return 'Unhealthy Stream';
case StreamState.Started:
return stream.isHealthy ? 'Active Stream' : 'Unhealthy Stream';
}
};
export default StreamCard;

Просмотреть файл

@ -0,0 +1,28 @@
import { Call, InjectionStream, NewInjectionStream, NewStream, Stream, StreamProtocol } from "../../models/calls/types";
export interface NewInjectionStreamDrawerProps {
call: Call | null;
newInjectionStream: NewInjectionStream | null;
}
export interface NewStreamDrawerProps {
call: Call | null;
newStream: NewStream | null;
}
export interface CallInfoProps {
call: Call | null;
streams: Stream[];
}
export interface CallStreamsProps {
callId: string;
callEnabled: boolean;
mainStreams: Stream[];
participantStreams: Stream[];
activeStreams: Stream[];
injectionStream: InjectionStream | null;
primarySpeakerEnabled: boolean;
stageEnabled: boolean;
callProtocol: StreamProtocol;
}

Просмотреть файл

@ -0,0 +1,12 @@
import React from 'react';
import { useSelector } from 'react-redux';
import IAppState from '../../services/store/IAppState';
const Footer: React.FC = () => {
const { initialized, app } = useSelector((state: IAppState) => state.config);
const versionString = initialized ? app?.buildNumber ?? '' : '';
return <div id="Footer">Teams TX {versionString}</div>;
};
export default Footer;

Просмотреть файл

@ -0,0 +1,56 @@
#Header {
background-color: #f3f2f1;
position: fixed;
top: 0;
right: 0;
z-index: 10;
width: 100%;
height: 70px;
border-bottom: 1px solid #ddd;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.025);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.025);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.025);
}
#Header h1 {
margin: 0;
padding: 0;
}
#Header h1 img {
display: inline-block;
height: 70px;
width: 70px;
padding: 10px;
}
#Header h1 span {
padding-left: 15px;
font-weight: bold;
position: relative;
top: 3px;
}
#Header h1 a {
color: black;
}
#Header h1 a:hover {
text-decoration: underline;
}
#Header .profile {
text-align: right;
padding: 10px;
}
#Header .profile .ant-avatar {
float: right;
}
#Header .profile .profileDetails {
display: inline-block;
padding: 8px 12px;
font-size: 0.8em;
}

Просмотреть файл

@ -0,0 +1,79 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Row, Col, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import NavBar from './NavBar';
import './Header.css';
import Logo from '../../images/logo.png';
import IAppState from '../../services/store/IAppState';
import { AuthStatus } from '../../models/auth/types';
import { signOut } from '../../stores/auth/asyncActions';
import { FEATUREFLAG_DISABLE_AUTHENTICATION } from '../../stores/config/constants';
const links = [
{
label: 'Join a Call',
to: '/call/join',
},
{
label: 'Calls',
to: '/',
},
{
label: 'Bot Service Status',
to: '/botservice',
},
];
const Header: React.FC = () => {
const dispatch = useDispatch();
const { status: authStatus, userProfile } = useSelector((state: IAppState) => state.auth);
const { app: appConfig } = useSelector((state: IAppState) => state.config);
const userName = userProfile?.username || '';
const role = authStatus === AuthStatus.Authenticated ? 'Producer' : 'None';
const disableAuthFlag = appConfig?.featureFlags && appConfig.featureFlags[FEATUREFLAG_DISABLE_AUTHENTICATION];
const onClickSignOut = () => {
dispatch(signOut(userName));
};
return (
<div id="Header">
<div id="HeaderInner">
<Row>
<Col span={6}>
<h1>
<Link to="/">
<img src={Logo} alt="" />
</Link>
</h1>
</Col>
<Col span={12}>
<NavBar links={links} />
</Col>
<Col span={6} className="profile">
<Avatar size={48} icon={<UserOutlined />} />
<span className="profileDetails">
<strong>{userName}</strong>
<br />
{role}
{!disableAuthFlag?.isActive && (
<>
{' '}
|
{' '}
<a href="#" onClick={onClickSignOut}>
Logout
</a>
</>
)}
</span>
</Col>
</Row>
</div>
</div>
);
};
export default Header;

Просмотреть файл

@ -0,0 +1,34 @@
#Nav ul {
list-style: none;
margin: 0;
padding: 0;
/* border: 1px solid red; */
/* text-align: center; */
/* position: absolute; */
text-align: center;
/* bottom: 0; */
}
#Nav ul li {
display: inline-block;
padding-right: 20px;
padding-top: 24px;
}
#Nav ul a {
display: block;
padding: 10px;
border-color: transparent;
color: 000;
transition: all 0.1s ease-in-out;
}
#Nav ul .selected {
font-weight: bold;
}
#Nav ul .selected a,
#Nav ul a:hover {
border-bottom: 4px solid #6364a9;
color: #6364a9;
}

Просмотреть файл

@ -0,0 +1,28 @@
import React from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import './NavBar.css';
interface INavBarDataProps extends RouteComponentProps {
links: Array<{label:string, to: string}>
}
const NavBar: React.FC<INavBarDataProps> = (props) => {
const navBarLinks = props.links.map(o => ({
...o,
selected: props.location.pathname === o.to
}));
return (
<div id="Nav">
<ul>
{navBarLinks.map(o => (
<li key={o.to} className={o.selected ? 'selected' : ''}><Link to={o.to}>{o.label}</Link></li>
))}
</ul>
</div>
);
}
export default withRouter(NavBar);

Просмотреть файл

@ -0,0 +1,30 @@
import React from "react";
import { useSelector } from "react-redux";
import { Route, Redirect } from "react-router-dom";
import { AuthStatus } from "../../models/auth/types";
import IAppState from "../../services/store/IAppState";
interface PrivateRouteProps {
component: React.ComponentType;
path: string;
exact?: boolean;
}
const PrivateRoute: React.FC<PrivateRouteProps> = ({
component: Component,
...rest
}) => {
const authStatus = useSelector((state: IAppState) => state.auth.status);
const isAuthenticated = authStatus === AuthStatus.Authenticated;
return (
<Route
{...rest}
render={() =>
isAuthenticated ? <Component /> : <Redirect to="/login" />
}
/>
);
};
export default PrivateRoute;

7
src/views/home/Home.css Normal file
Просмотреть файл

@ -0,0 +1,7 @@
#CallsInfoActions p {
margin: 20px 0;
}
.joinNew {
clear: both;
}

59
src/views/home/Home.tsx Normal file
Просмотреть файл

@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Button, Spin } from 'antd';
import './Home.css';
import ActiveCallCard from './components/ActiveCallCard';
import { getActiveCallsAsync } from '../../stores/calls/asyncActions';
import * as CallsActions from '../../stores/calls/actions';
import { selectRequesting } from '../../stores/requesting/selectors';
import IAppState from '../../services/store/IAppState';
import { selectActiveCalls } from '../../stores/calls/selectors';
const Home: React.FC = () => {
const dispatch = useDispatch();
const isRequesting = useSelector((state: IAppState) => selectRequesting(state, [CallsActions.REQUEST_ACTIVE_CALLS]));
const activeCalls = useSelector((state: IAppState) => selectActiveCalls(state));
useEffect(() => {
dispatch(getActiveCallsAsync());
}, []);
const hasCalls = activeCalls.length > 0;
return (
<div>
<h2>Active Calls</h2>
{!hasCalls && (
<>
{isRequesting && (
<div><Spin /></div>
)}
{!isRequesting && (
<>
<h3>There are no active calls.</h3>
<p>
Please <Link to="/call/join"><strong>join the bot</strong></Link> to an active call to start.
</p>
</>
)}
</>
)}
{hasCalls && (
<>
{activeCalls.map(o => <ActiveCallCard key={o.id} call={o} />)}
<div className="joinNew">
<Button type="primary" size="large">
<Link to="/call/join">Join a new Call</Link>
</Button>
</div>
</>
)}
</div>
)
}
export default Home;

Просмотреть файл

@ -0,0 +1,106 @@
.activeCallCard {
float: left;
min-width: 400px;
position: relative;
}
@media only screen and (max-width: 1300px) {
.activeCallCard {
min-width: 460px;
width: 50%;
}
}
@media only screen and (min-width: 1300px) {
.activeCallCard {
min-width: auto;
width: 33%;
}
}
@media only screen and (min-width: 2000px) {
.activeCallCard {
min-width: auto;
width: 25%;
}
}
.activeCallCard .content {
position: relative;
height: 200px;
margin: 0px 20px 20px 0;
border-radius: 4px 4px 24px 4px;
background-color: #fff;
transition: all 0.1s ease-in-out;
padding: 20px;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
flex-wrap: nowrap;
overflow: hidden;
}
.activeCallCard .content h3 {
font-size: 1.4em;
font-weight: bold;
}
.activeCallCard .status {
text-transform: uppercase;
font-weight: bold;
}
/* palette - disconnected / non-streaming */
.activeCallCard .content .status {
color: #bdbdbd;
}
/* palette - establised */
.activeCallCard.healthy .content .status {
color: #73c856;
}
/* palette - initializing/connecting */
.activeCallCard.initializing .content .status {
color: #f3ca3e;
}
/* palette - error/unhealthy */
.activeCallCard.error .content .status {
color: #df3639;
}
.activeCallCard p {
padding: 0;
margin: 0 0 4px 0;
}
.activeCallCard a.action {
position: absolute;
bottom: 20px;
right: 20px;
display: inline-flex;
width: 60px;
height: 60px;
text-align: center;
border-radius: 60px;
vertical-align: middle;
transition: all 0.1s ease-in-out;
background-color: #1890ff;
color: #fff;
}
.activeCallCard a.action:hover {
background-color: #000;
color: #fff;
}
.activeCallCard a.action svg {
display: block;
margin: auto;
}

Просмотреть файл

@ -0,0 +1,64 @@
import React from 'react';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { Link } from 'react-router-dom';
import moment from 'moment'
import './ActiveCallCard.css';
import { Call, CallState, CallType } from '../../../models/calls/types';
interface ICallCardDataProps {
call: Call;
}
type ICallCardProps = ICallCardDataProps;
const ActiveCallCard: React.FC<ICallCardProps> = ({ call }) => {
const status = getConnectionStatus(call);
const classes = ['activeCallCard', getConnectionClass(call)];
// creation formatting
const creationDate = moment(call.createdAt);
const datePart = creationDate.format('L'); // 07/03/2020
const timePart = creationDate.format('LTS'); // 5:29:19 PM
// const minutesPassed = creationDate.startOf('hour').fromNow(); // 14 minutes ago
const formattedCreation = datePart + " " + timePart; // + " (" + minutesPassed + ")";
return (
<div className={classes.join(' ')}>
<div className="content">
<h3>{call.displayName}</h3>
<p>Status: <span className="status">{status}</span></p>
<p><strong>{CallTypeStrings[call.meetingType]}</strong> | Created : <strong>{formattedCreation}</strong></p>
<Link to={`/call/details/${call.id}`} title="Open details" className="action">
<ChevronRightIcon fontSize="large" />
</Link>
</div>
</div>
);
}
export default ActiveCallCard;
const getConnectionStatus = (call: Call): string => {
switch (call.state) {
case CallState.Establishing: return 'Connecting';
case CallState.Established: return 'Connected';
case CallState.Terminating: return 'Disconnecting';
case CallState.Terminated: return 'Disconnected';
}
}
const getConnectionClass = (call: Call): string => {
switch (call.state) {
case CallState.Establishing: return 'initializing';
case CallState.Established: return 'healthy';
case CallState.Terminating: return 'disconnecting';
case CallState.Terminated: return 'disconnected';
}
}
const CallTypeStrings = {
[CallType.Default]: 'Normal call',
[CallType.Event]: 'Event call'
};

17
src/views/home/types.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
export enum TeamsColors {
Red = "#D74654",
Purple = "#6264A7",
Black = "#11100F",
Green = "#7FBA00",
Grey = "#BEBBB8",
MiddleGrey = "#3B3A39",
DarkGrey = "#201F1E",
White = "white"
}
export enum TeamsMargins {
micro = "4px",
small = "8px",
medium = "20px",
large = "40px",
}

Просмотреть файл

@ -0,0 +1,19 @@
#HomeBody {
padding: 30px;
}
.StatusIcon {
margin-right: 20px;
}
.CallUrl {
display: inline-block;
max-width: 700px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.errorRow {
color: red;
}

Просмотреть файл

@ -0,0 +1,106 @@
import React, { createRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Input, Button, Form, Row, Col } from "antd";
import { Store } from "antd/lib/form/interface";
import { Rule, FormInstance } from "antd/lib/form";
import HourglassEmpty from "@material-ui/icons/HourglassEmpty";
import "./JoinCall.css";
import { selectNewCall } from "../../stores/calls/selectors";
import IAppState from "../../services/store/IAppState";
import { selectRequesting } from "../../stores/requesting/selectors";
import { extractLinks } from "../../services/helpers";
import * as CallsActions from '../../stores/calls/actions';
import { joinCallAsync } from "../../stores/calls/asyncActions";
import { CallState } from "../../models/calls/types";
const { Item } = Form;
const MEETING_URL_PATTERN = /https:\/\/teams\.microsoft\.com\/l\/meetup-join\/(.*)/;
const JoinCall: React.FC = (props) => {
const dispatch = useDispatch();
const connectingCall = useSelector((state: IAppState) => selectNewCall(state));
const isRequesting: boolean = useSelector((state: IAppState) => selectRequesting(state, [CallsActions.REQUEST_JOIN_CALL]));
const formRef = createRef<FormInstance>();
const handlePaste = (data: DataTransfer) => {
const html = data.getData("text/html");
if (html && html.indexOf('href="https://teams.microsoft.com/l/meetup-join"') > -1) {
// extract links
const links = extractLinks(html);
const meetingLink = links.find((o) => MEETING_URL_PATTERN.test(o));
if (meetingLink) {
console.log(meetingLink);
setTimeout(() => formRef.current?.setFieldsValue({ callUrl: meetingLink }), 1);
}
}
};
// When form is completed correctly
const onFinish = (form: Store) => {
console.log("form", form);
// Trigger JoinCall AsyncAction
dispatch(joinCallAsync(form.callUrl));
};
// Validation
const callUrlRules: Rule[] = [
{
required: true,
whitespace: false,
message: "Please add your Teams Invite URL.",
pattern: MEETING_URL_PATTERN,
},
];
// ui parameters
const connecting = connectingCall?.status === CallState.Establishing;
return (
<>
<Form ref={formRef} onFinish={onFinish}>
<div id="JoinCall" className="PageBody">
<div id="CallsInfo">
<h2>Connect to a new call</h2>
<div id="CallsInfoActions">
<Item label="Invite URL:" name="callUrl" rules={callUrlRules}>
<Input
placeholder="https://..."
disabled={isRequesting}
onPasteCapture={(e) => handlePaste(e.clipboardData)}
/>
</Item>
</div>
</div>
</div>
<Button type="primary" size="large" htmlType="submit" disabled={isRequesting}>
Join Call
</Button>
<br />
<br />
{isRequesting && (
<Row>
<Col className="StatusIcon">
<HourglassEmpty style={{ fontSize: "62px" }} />
</Col>
<Col>
<p className="CallUrl">
<strong>Joining {connectingCall?.callUrl}</strong>
</p>
<p>Please wait while the bot joins...</p>
</Col>
</Row>
)}
</Form>
</>
);
};
export default JoinCall

Просмотреть файл

@ -0,0 +1,50 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Button, Card } from "antd";
import { LoginOutlined } from "@ant-design/icons";
import { Redirect } from "react-router-dom";
import { signIn } from "../../stores/auth/asyncActions";
import IAppState from "../../services/store/IAppState";
import { AuthStatus } from "../../models/auth/types";
const LoginPage: React.FC = () => {
const dispatch = useDispatch();
const authStatus = useSelector((state: IAppState) => state.auth.status);
const isAuthenticated = authStatus === AuthStatus.Authenticated;
const { Meta } = Card;
const onClickSignIn = () => {
dispatch(signIn());
}
return isAuthenticated ? (
<Redirect to="/" />
) : (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
}}
>
<Card
title="Login with your account"
bordered
style={{ width: 300, textAlign: "center" }}
size="small"
>
<Meta description="You must log in with your microsoft account to continue." />
<hr />
<Button
type="primary"
icon={<LoginOutlined />}
shape="round"
onClick={onClickSignIn}
>
Login with your account
</Button>
</Card>
</div>
);
};
export default LoginPage;

Просмотреть файл

@ -0,0 +1,7 @@
#CallsInfoActions p {
margin: 20px 0;
}
.joinNew {
clear: both;
}

Просмотреть файл

@ -0,0 +1,59 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Spin } from 'antd';
import './BotServiceStatus.css';
import BotServiceStatusCard from './BotServiceStatusCard';
import * as BotServiceActions from '../../stores/service/actions';
import { selectRequesting } from '../../stores/requesting/selectors';
import IAppState from '../../services/store/IAppState';
import { getBotServiceAsync, startBotServiceAsync, stopBotServiceAsync } from '../../stores/service/asyncActions';
const ServicePage: React.FC = () => {
const dispatch = useDispatch();
const { botServices } = useSelector((state: IAppState) => state.botServiceStatus);
const isRequesting: boolean = useSelector((state: IAppState) =>
selectRequesting(state, [BotServiceActions.REQUEST_BOT_SERVICE])
);
React.useEffect(() => {
// Array of one virtual machine will be fetched
dispatch(getBotServiceAsync());
}, []);
const hasBotServices = botServices.length > 0;
return (
<div>
<h2>Bot Services</h2>
{!hasBotServices && (
<>
{isRequesting && (
<div>
<Spin />
</div>
)}
{isRequesting && (
<>
<h3>There are no Bot Services.</h3>
</>
)}
</>
)}
{hasBotServices && (
<>
{botServices.map((botService, i) => (
<BotServiceStatusCard
key={i}
loading={isRequesting}
name={botService.name}
botService={botService}
onStart={() => dispatch(startBotServiceAsync())}
onStop={() => dispatch(stopBotServiceAsync())}
onRefresh={() => dispatch(getBotServiceAsync())}
/>
))}
</>
)}
</div>
);
};
export default ServicePage;

Просмотреть файл

@ -0,0 +1,118 @@
#CardHeader {
background-color: #1890FF;
}
#BotServiceStatusCard {
float: left;
min-width: 300px;
position: relative;
margin: auto;
background-color: #FFFFFF;
}
#buttonGroup1 {
border: 0px;
border-style: none;
}
@media only screen and (max-width: 1300px) {
.BotServiceStatusCard {
min-width: 460px;
width: 50%;
}
}
@media only screen and (min-width: 1300px) {
.BotServiceStatusCard {
min-width: auto;
width: 33%;
}
}
@media only screen and (min-width: 2000px) {
.BotServiceStatusCard {
min-width: auto;
width: 25%;
}
}
#BotServiceStatusCard .content {
position: relative;
height: 200px;
width: 200px;
margin: 0px 20px 20px 0;
border-radius: 4px 4px 24px 4px;
background-color: #fff;
transition: all 0.1s ease-in-out;
padding: 20px;
/* shadow */
-webkit-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
-moz-box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, 0.1);
flex-wrap: nowrap;
overflow: hidden;
}
.BotServiceStatusCard .content h3 {
font-size: 1.4em;
font-weight: bold;
}
.BotServiceStatusCard .status {
text-transform: uppercase;
font-weight: bold;
}
/* palette - disconnected / non-streaming */
.BotServiceStatusCard .content .status {
color: #bdbdbd;
}
/* palette - establised */
.BotServiceStatusCard.healthy .content .status {
color: #73c856;
}
/* palette - initializing/connecting */
.BotServiceStatusCard.initializing .content .status {
color: #f3ca3e;
}
/* palette - error/unhealthy */
.BotServiceStatusCard.error .content .status {
color: #df3639;
}
.BotServiceStatusCard p {
padding: 0;
margin: 0 0 4px 0;
}
.BotServiceStatusCard a.action {
position: absolute;
bottom: 20px;
right: 20px;
display: inline-flex;
width: 60px;
height: 60px;
text-align: center;
border-radius: 60px;
vertical-align: middle;
transition: all 0.1s ease-in-out;
background-color: #1890ff;
color: #fff;
}
.BotServiceStatusCard a.action:hover {
background-color: #000;
color: #fff;
}
.BotServiceStatusCard a.action svg {
display: block;
margin: auto;
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше