Add source code to the repository
This commit is contained in:
Родитель
05d4d9cf94
Коммит
a75b88a9a1
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 1.0 KiB |
|
@ -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>
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"routes": [
|
||||
{
|
||||
"route": "/*",
|
||||
"serve": "/index.html",
|
||||
"statusCode": 200
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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;
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 6.7 KiB |
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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",
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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',
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -0,0 +1 @@
|
|||
#WIP
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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}));
|
||||
};
|
|
@ -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:
|
||||
<strong>
|
||||
{CallType[callInfoProps.call.meetingType]} - ({participantsLength} participant
|
||||
{participantsLength !== 1 ? 's' : null})
|
||||
</strong>
|
||||
</span>
|
||||
<span className="CallInfoProperty">
|
||||
Created: <strong>{formattedCreationDate}</strong>
|
||||
</span>
|
||||
|
||||
{/* Defaults - READ Mode */}
|
||||
{connected && !editingDefaults && (
|
||||
<div id="CallInfoForm">
|
||||
<span className="CallInfoProperty">
|
||||
Default protocol:
|
||||
<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:
|
||||
<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:
|
||||
<strong>
|
||||
<Text copyable>{callInfoProps.call.botFqdn}</Text>
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{callInfoProps.call.botIp && (
|
||||
<span className="CallInfoProperty">
|
||||
Bot IP:
|
||||
<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'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'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 "Start" 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;
|
|
@ -0,0 +1,7 @@
|
|||
#CallsInfoActions p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.joinNew {
|
||||
clear: both;
|
||||
}
|
|
@ -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'
|
||||
};
|
|
@ -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;
|
||||
}
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче