Add example of Teams interoperation (#172)
Add an example for how an application might show a banner when a Teams call is being recorded or transcribed.
This commit is contained in:
Родитель
f32d1a3d46
Коммит
82250083c2
|
@ -0,0 +1,27 @@
|
|||
import { Description, Heading, Source, Title } from '@storybook/addon-docs/blocks';
|
||||
import React from 'react';
|
||||
|
||||
const CallComponentText = require('!!raw-loader!./snippets/CallComponent.snippet.tsx').default;
|
||||
const TeamsInteropText = require('!!raw-loader!./snippets/TeamsInterop.snippet.tsx').default;
|
||||
|
||||
export const getDocs: () => JSX.Element = () => {
|
||||
return (
|
||||
<>
|
||||
<Title>Teams Interop</Title>
|
||||
<Description>
|
||||
Azure Communication Services applications can inter-operate with Microsoft Teams. There are some additional
|
||||
considerations when connecting to a Teams meeting.
|
||||
</Description>
|
||||
<Heading>Compliance notifications for recording and transcription</Heading>
|
||||
<Description>
|
||||
This example shows how you might notify your users when a Teams meeting is being recorded or transcribed. Here,
|
||||
a MessageBar is optionally added to the video frame:
|
||||
</Description>
|
||||
<Source code={CallComponentText} />
|
||||
<Description>
|
||||
The state machine tracking when and what banner to display can be encapsulated in a vanilla Typescript package:
|
||||
</Description>
|
||||
<Source code={TeamsInteropText} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
import { button } from '@storybook/addon-knobs';
|
||||
import { Meta } from '@storybook/react/types-6-0';
|
||||
import React, { useState } from 'react';
|
||||
import { EXAMPLES_FOLDER_PREFIX } from '../../constants';
|
||||
import { CallComponent } from './snippets/CallComponent.snippet';
|
||||
import { getDocs } from './Docs';
|
||||
|
||||
export const NoticeBanner: () => JSX.Element = () => {
|
||||
const [teamsInterop, setTeamsInterop] = useState({
|
||||
recordingEnabled: false,
|
||||
transcriptionEnabled: false
|
||||
});
|
||||
|
||||
button('Toggle Recording', () => {
|
||||
setTeamsInterop({
|
||||
recordingEnabled: !teamsInterop.recordingEnabled,
|
||||
transcriptionEnabled: teamsInterop.transcriptionEnabled
|
||||
});
|
||||
// Without an explicit return, the Canvas iframe is re-rendered, and all Components are recreated.
|
||||
// This causes the state in this component to be lost.
|
||||
return false;
|
||||
});
|
||||
button('Toggle Transcription', () => {
|
||||
setTeamsInterop({
|
||||
recordingEnabled: teamsInterop.recordingEnabled,
|
||||
transcriptionEnabled: !teamsInterop.transcriptionEnabled
|
||||
});
|
||||
// Without an explicit return, the Canvas iframe is re-rendered, and all Components are recreated.
|
||||
// This causes the state in this component to be lost.
|
||||
return false;
|
||||
});
|
||||
|
||||
// TODO: Fix dark theming.
|
||||
// Once https://github.com/Azure/communication-ui-sdk/pull/169 lands, same fix should be applied here.
|
||||
return <CallComponent {...teamsInterop} />;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: `${EXAMPLES_FOLDER_PREFIX}/TeamsInterop`,
|
||||
component: NoticeBanner,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: () => getDocs()
|
||||
}
|
||||
}
|
||||
} as Meta;
|
|
@ -0,0 +1,37 @@
|
|||
import { MessageBar } from '@fluentui/react';
|
||||
import React, { useRef } from 'react';
|
||||
import { bannerMessage, TeamsInterop } from './TeamsInterop.snippet';
|
||||
|
||||
export const Banner = (props: TeamsInterop): JSX.Element => {
|
||||
const history = useRef({
|
||||
teamsInteropCurrent: {
|
||||
recordingEnabled: false,
|
||||
transcriptionEnabled: false
|
||||
},
|
||||
teamsInteropPrevious: {
|
||||
recordingEnabled: false,
|
||||
transcriptionEnabled: false
|
||||
}
|
||||
});
|
||||
|
||||
// Only update history if props differ from the latest snapshot in the history.
|
||||
// This avoids jank caused by duplicate renders without any intervening prop update.
|
||||
if (
|
||||
props.recordingEnabled !== history.current.teamsInteropCurrent.recordingEnabled ||
|
||||
props.transcriptionEnabled !== history.current.teamsInteropCurrent.transcriptionEnabled
|
||||
) {
|
||||
history.current = {
|
||||
teamsInteropCurrent: {
|
||||
recordingEnabled: props.recordingEnabled,
|
||||
transcriptionEnabled: props.transcriptionEnabled
|
||||
},
|
||||
teamsInteropPrevious: {
|
||||
recordingEnabled: history.current.teamsInteropCurrent.recordingEnabled,
|
||||
transcriptionEnabled: history.current.teamsInteropCurrent.transcriptionEnabled
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const msg = bannerMessage(history.current);
|
||||
return msg !== null ? <MessageBar>{msg}</MessageBar> : <></>;
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import { StreamMedia, VideoTile } from '@azure/communication-ui';
|
||||
import React from 'react';
|
||||
import { renderVideoStream } from '../../../utils';
|
||||
import { Banner } from './Banner.snippet';
|
||||
import { CallControlBar } from './CallControlBar.snippet';
|
||||
import { TeamsInterop } from './TeamsInterop.snippet';
|
||||
|
||||
export interface CallProps {
|
||||
teamsInteropCurrent: TeamsInterop;
|
||||
teamsInteropPrevious: TeamsInterop;
|
||||
}
|
||||
|
||||
export const CallComponent = (props: TeamsInterop): JSX.Element => {
|
||||
const videoTileStyles = {
|
||||
root: { height: '100%', width: '100%' },
|
||||
overlayContainer: {}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoTile
|
||||
styles={videoTileStyles}
|
||||
invertVideo={true}
|
||||
isVideoReady={true}
|
||||
videoProvider={
|
||||
// Replace with your own video provider.
|
||||
<StreamMedia videoStreamElement={renderVideoStream()} />
|
||||
}
|
||||
placeholderProvider={<></>}
|
||||
>
|
||||
{/* Optional Banner */}
|
||||
<Banner {...props} />
|
||||
|
||||
<CallControlBar />
|
||||
</VideoTile>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
ControlBar,
|
||||
labeledAudioButtonProps,
|
||||
labeledHangupButtonProps,
|
||||
labeledOptionsButtonProps,
|
||||
labeledVideoButtonProps
|
||||
} from '@azure/communication-ui';
|
||||
import { DefaultButton } from '@fluentui/react';
|
||||
import React from 'react';
|
||||
|
||||
// TODO: Add unique keys to the list here.
|
||||
export const CallControlBar = (): JSX.Element => {
|
||||
return (
|
||||
<ControlBar
|
||||
layout="dockedBottom"
|
||||
styles={{ root: { background: 'white', minHeight: '4.25rem', alignItems: 'center' } }}
|
||||
>
|
||||
<DefaultButton {...labeledVideoButtonProps} checked={true} />
|
||||
<DefaultButton {...labeledAudioButtonProps} checked={true} />
|
||||
<DefaultButton {...labeledOptionsButtonProps} />
|
||||
<DefaultButton {...labeledHangupButtonProps} style={{ borderRadius: '0.25rem', marginLeft: '0.25rem' }} />
|
||||
</ControlBar>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,217 @@
|
|||
import { Link } from '@fluentui/react';
|
||||
import React from 'react';
|
||||
|
||||
// State tracking for Teams interop.
|
||||
//
|
||||
// In a real application, this would be fetched from ACS APIs.
|
||||
export interface TeamsInterop {
|
||||
recordingEnabled: boolean;
|
||||
transcriptionEnabled: boolean;
|
||||
}
|
||||
|
||||
// Historical state of Teams interop, for computing state transition events.
|
||||
export interface TeamsInteropHistory {
|
||||
teamsInteropCurrent: TeamsInterop;
|
||||
teamsInteropPrevious: TeamsInterop;
|
||||
}
|
||||
|
||||
// Return the banner message to display for teams interop.
|
||||
//
|
||||
// Returns null if no banner needs to be displayed.
|
||||
export function bannerMessage(props: TeamsInteropHistory): JSX.Element | null {
|
||||
/*
|
||||
This function implements a single transition in a state machine.
|
||||
Nodes of the state machine correspond to the possible values of the TeamsInterp tuple:
|
||||
(recordingEnabled, transcriptionEnabled).
|
||||
Thus there are 4 distinct states.
|
||||
|
||||
There are 12 transitions between the 4 states.
|
||||
The diagram below omits the transitions 2->3, 3->2, 1->4 and 4->1 for clarity.
|
||||
|
||||
1. 2.
|
||||
┌────────────────┐ 1->2 ┌────────────────┐
|
||||
│ ! recording ├──────────────────►│ ! recording │
|
||||
│ ! transcribing │ │ transcribing │
|
||||
└──┬─────────────┘◄──────────────────┴─┬──────────────┘
|
||||
│1->3 ▲ 2->1 │2->4 ▲
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
▼ 3->1 │ ▼ 4->2 │
|
||||
┌──────────────┴─┐ 3->4 ┌──────────────┴─┐
|
||||
│ recording ├──────────────────►│ recording │
|
||||
│ ! transcribing │ │ transcribing │
|
||||
└────────────────┘◄──────────────────┴────────────────┘
|
||||
3. 4->3 4.
|
||||
|
||||
The implementation consists of nested if blocks. The outer conditional selects the source node
|
||||
and the inner conditional selects the transition out of the node.
|
||||
*/
|
||||
const prev = props.teamsInteropPrevious;
|
||||
const cur = props.teamsInteropCurrent;
|
||||
|
||||
// Source: 4
|
||||
if (prev.recordingEnabled && prev.transcriptionEnabled) {
|
||||
// Transition: 4 -> 3
|
||||
if (cur.recordingEnabled && !cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Transcription has stopped.</b> You are now only recording this meeting.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 4 -> 2
|
||||
if (!cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording has stopped.</b> You are now only transcribing this meeting.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 4 -> 1
|
||||
if (!cur.recordingEnabled && !cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording and transcription are being saved. </b> Recording and transcription have stopped.
|
||||
<LearnMore />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Source: 3
|
||||
if (prev.recordingEnabled && !prev.transcriptionEnabled) {
|
||||
// Transition: 3 -> 4
|
||||
if (cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording and transcription have started.</b> By joining, you are giving consent for this meeting to be
|
||||
transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 3 -> 2
|
||||
if (!cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
// This could be considered either:
|
||||
// - Recording was stopped
|
||||
// - Transcription was started.
|
||||
// We prefer notifying users of a start event because it links to the privacy policy.
|
||||
return (
|
||||
<>
|
||||
<b>Transcription has started.</b> By joining, you are giving consent for this meeting to be transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 3 -> 1
|
||||
if (!cur.recordingEnabled && !cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording is being saved.</b> Recording has stopped.
|
||||
<LearnMore />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Source: 2
|
||||
if (!prev.recordingEnabled && prev.transcriptionEnabled) {
|
||||
// Transition: 3 -> 4
|
||||
if (cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording and transcription have started.</b> By joining, you are giving consent for this meeting to be
|
||||
transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 3 -> 2
|
||||
if (!cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
// This could be considered either:
|
||||
// - Transcription was stopped
|
||||
// - Recording was started.
|
||||
// We prefer notifying users of a start event because it links to the privacy policy.
|
||||
return (
|
||||
<>
|
||||
<b>Recording has started.</b> By joining, you are giving consent for this meeting to be transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 3 -> 1
|
||||
if (!cur.recordingEnabled && !cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Transcription is being saved.</b> Transcription has stopped.
|
||||
<LearnMore />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Source: 1
|
||||
if (!prev.recordingEnabled && !prev.transcriptionEnabled) {
|
||||
// Transition: 1 -> 4
|
||||
if (cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording and transcription have started.</b> By joining, you are giving consent for this meeting to be
|
||||
transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 1 -> 3
|
||||
if (cur.recordingEnabled && !cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Recording has started.</b> By joining, you are giving consent for this meeting to be transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Transition: 1 -> 2
|
||||
if (!cur.recordingEnabled && cur.transcriptionEnabled) {
|
||||
return (
|
||||
<>
|
||||
<b>Transcription has started.</b> By joining, you are giving consent for this meeting to be transcribed.
|
||||
<PrivacyPolicy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PrivacyPolicy(): JSX.Element {
|
||||
return (
|
||||
<Link
|
||||
href="https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule"
|
||||
target="_blank"
|
||||
underline
|
||||
>
|
||||
Privacy policy
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnMore(): JSX.Element {
|
||||
return (
|
||||
<Link
|
||||
href="https://support.microsoft.com/en-us/office/record-a-meeting-in-teams-34dfbe7f-b07d-4a27-b4c6-de62f1348c24"
|
||||
target="_blank"
|
||||
underline
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
);
|
||||
}
|
Загрузка…
Ссылка в новой задаче