├── src ├── react-app-env.d.ts ├── App │ ├── TeamsAuth │ │ └── TeamsAuth.tsx │ ├── EventView │ │ ├── EventDetailsView │ │ │ ├── EventDetailsView.css │ │ │ └── EventDetailsView.tsx │ │ ├── BreakoutView │ │ │ ├── BreakoutView.css │ │ │ ├── BreakoutsCreatorView │ │ │ │ ├── BreakoutsCreatorView.css │ │ │ │ └── BreakoutsCreatorView.tsx │ │ │ └── BreakoutView.tsx │ │ └── EventView.tsx │ ├── TeamsTabConfig │ │ └── TabConfig.tsx │ ├── AgendaView │ │ ├── AgendaView.css │ │ └── AgendaView.tsx │ ├── App.css │ └── App.tsx ├── utils │ ├── graph.ts │ ├── helpers.ts │ ├── types.ts │ ├── date.ts │ ├── graph.events.ts │ ├── graph.onlineMeetings.ts │ └── graph.teams.ts ├── index.tsx ├── images │ ├── move.svg │ ├── logo.svg │ └── tandem-bicycle.svg └── serviceWorker.ts ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── service-worker.js ├── web.config ├── vendor │ └── custom-elements-es5-adapter.js └── index.html ├── .publish ├── color.png ├── outline.png ├── Development.zip ├── Development.env └── manifest.json ├── .vscode └── settings.json ├── .gitignore ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── LICENSE ├── package.json ├── SECURITY.md └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.publish/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/.publish/color.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/public/logo512.png -------------------------------------------------------------------------------- /.publish/outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/.publish/outline.png -------------------------------------------------------------------------------- /.publish/Development.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/meeting-moderator-sample/HEAD/.publish/Development.zip -------------------------------------------------------------------------------- /.publish/Development.env: -------------------------------------------------------------------------------- 1 | version=1.0.0 2 | appname=Moderator 3 | fullappname=Meeting Moderator 4 | baseUrl0=https://moderator.ngrok.io 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appService.defaultWebAppToDeploy": "/subscriptions/da725df1-c7e2-42fa-9e98-73c61d7edc4f/resourceGroups/demos/providers/Microsoft.Web/sites/my-moderator", 3 | "appService.deploySubpath": "build" 4 | } -------------------------------------------------------------------------------- /src/App/TeamsAuth/TeamsAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TeamsProvider } from '@microsoft/mgt-teams-provider'; 3 | 4 | export function TeamsAuth() { 5 | TeamsProvider.handleAuth(); 6 | 7 | return ( 8 |
Now signing you in!
9 | ); 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .env.development -------------------------------------------------------------------------------- /src/utils/graph.ts: -------------------------------------------------------------------------------- 1 | import { Providers, ProviderState } from '@microsoft/mgt-element'; 2 | 3 | export const getClient = () => { 4 | if (Providers.globalProvider.state === ProviderState.SignedIn) { 5 | return Providers.globalProvider.graph.client; 6 | } 7 | 8 | return null; 9 | } 10 | 11 | export const getCurrentSignedInUser = async () => { 12 | try { 13 | return await getClient()?.api('me').get(); 14 | } catch (e) { 15 | return null; 16 | } 17 | } -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { TeamsHelper } from "@microsoft/mgt-element" 2 | import * as MicrosoftTeams from '@microsoft/teams-js'; 3 | 4 | export const openTeamsUrl = (url: string) => { 5 | if (TeamsHelper.isAvailable) { 6 | MicrosoftTeams.initialize(() => { 7 | console.log(url) 8 | MicrosoftTeams.executeDeepLink(url, (success) => { 9 | if (!success) { 10 | window.open(url, '_blank'); 11 | } 12 | }); 13 | }) 14 | } else { 15 | window.open(url, '_blank') 16 | } 17 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | - Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) 11 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dir" : "ltr", 3 | "lang" : "en", 4 | "name" : "Moderator", 5 | "scope" : "/", 6 | "display" : "standalone", 7 | "start_url" : "/", 8 | "short_name" : "Moderator", 9 | "theme_color" : "#0078d4", 10 | "description" : "Do you need help moderating small group breakouts in Microsoft Teams online meetings? Look no further!!! ;)", 11 | "orientation" : "any", 12 | "background_color" : "#0078d4", 13 | "related_applications" : [], 14 | "prefer_related_applications" : false, 15 | "icons" : [ 16 | { 17 | "src": "/logo192.png", 18 | "sizes": "192x192" 19 | }], 20 | "url" : "/" 21 | } -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | // This is the service worker with the Cache-first network 2 | 3 | const CACHE = "pwabuilder-precache"; 4 | 5 | importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js'); 6 | 7 | self.addEventListener("message", (event) => { 8 | if (event.data && event.data.type === "SKIP_WAITING") { 9 | self.skipWaiting(); 10 | } 11 | }); 12 | 13 | workbox.routing.registerRoute( 14 | new RegExp('https://graph.microsoft.com/*'), 15 | new workbox.strategies.NetworkOnly() 16 | ) 17 | 18 | workbox.routing.registerRoute( 19 | new RegExp('/*'), 20 | new workbox.strategies.CacheFirst({ 21 | cacheName: CACHE 22 | }) 23 | ); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 2 | 3 | // TODO - graph types package is not up to date with the API 4 | export type GraphEvent = MicrosoftGraph.Event & { 5 | isOnlineMeeting: boolean, 6 | onlineMeeting: { 7 | joinUrl: string 8 | }, 9 | onlineMeetingProvider: 'teamsForBusiness' | 'skypeForBusiness' | 'skypeForConsumer' 10 | } 11 | 12 | export type Team = microsoftgraphbeta.Team & { 13 | 'template@odata.bind': string 14 | } 15 | 16 | export interface GroupInfo { 17 | id: string, 18 | name: string, 19 | onlineMeeting: string, 20 | members: MicrosoftGraph.User[] 21 | } 22 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/vendor/custom-elements-es5-adapter.js: -------------------------------------------------------------------------------- 1 | /** 2 | @license @nocompile 3 | Copyright (c) 2018 The Polymer Project Authors. All rights reserved. 4 | This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 | The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 | The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 | Code distributed by Google as part of the polymer project is also 8 | subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9 | */ 10 | (function () { 11 | 'use strict'; 12 | 13 | (function(){if(void 0===window.Reflect||void 0===window.customElements||window.customElements.polyfillWrapFlushCallback)return;const a=HTMLElement;window.HTMLElement={HTMLElement:function HTMLElement(){return Reflect.construct(a,[],this.constructor)}}.HTMLElement,HTMLElement.prototype=a.prototype,HTMLElement.prototype.constructor=HTMLElement,Object.setPrototypeOf(HTMLElement,a);})(); 14 | 15 | }()); 16 | -------------------------------------------------------------------------------- /src/App/EventView/EventDetailsView/EventDetailsView.css: -------------------------------------------------------------------------------- 1 | .EventDetailsContainer { 2 | margin: 20px 0; 3 | } 4 | 5 | .EventDetails { 6 | margin:30px 0 20px 0; 7 | } 8 | 9 | .EventDetail { 10 | display: flex; 11 | align-items: flex-start; 12 | margin-bottom: 10px; 13 | } 14 | .EventKey { 15 | width: 60px; 16 | font-size: 14px; 17 | line-height: 20px; 18 | color: #201F1E; 19 | opacity: 0.5; 20 | display: inline-block; 21 | flex-shrink: 0; 22 | } 23 | 24 | .EventValue { 25 | font-size: 14px; 26 | line-height: 20px; 27 | color: #201F1E; 28 | display: inline-block; 29 | max-width: 430px; 30 | word-break: break-all; 31 | flex: 0 1 430px; 32 | } 33 | 34 | .JoinButton { 35 | margin-top: 20px; 36 | margin-left: -8px; 37 | } 38 | 39 | .ParticipantsList { 40 | display: flex; 41 | flex-wrap: wrap; 42 | margin-top: 20px; 43 | } 44 | 45 | .ParticipantsList .Participant { 46 | width: 160px; 47 | margin: 8px 30px 8px 0; 48 | overflow: hidden; 49 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Microsoft Graph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/App/EventView/BreakoutView/BreakoutView.css: -------------------------------------------------------------------------------- 1 | 2 | .Breakouts { 3 | margin-top: 20px; 4 | position: relative; 5 | } 6 | 7 | .BreakoutsTitleContainer { 8 | display: flex; 9 | flex-direction: row; 10 | flex-wrap: wrap; 11 | } 12 | 13 | .BreakoutsTitleContainer .CardTitle { 14 | flex-grow: 1; 15 | margin-bottom: 10px; 16 | } 17 | 18 | .Breakouts .MessageTextField { 19 | margin-top: 20px; 20 | max-width: 430px; 21 | } 22 | 23 | .Breakouts .PlacedholderDescription { 24 | font-size: 12px; 25 | opacity: 0.8; 26 | margin: 10px 0 20px 0; 27 | } 28 | 29 | .Breakouts .MessageSendButtonContainer { 30 | height: 32px; 31 | } 32 | 33 | .Breakouts .MessageSpinner { 34 | justify-content: flex-start; 35 | } 36 | 37 | .Breakouts .BreakoutGroups { 38 | margin-top: 40px; 39 | display: flex; 40 | flex-wrap: wrap; 41 | } 42 | 43 | .GroupActions { 44 | height: 40px; 45 | background: #F5F5F5; 46 | border-radius: 2px; 47 | } 48 | 49 | /* .Breakouts .BreakoutsArchiveButton { 50 | position: absolute; 51 | top: 20px; 52 | right: 20px; 53 | } */ -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App/App'; 4 | 5 | import {Msal2Provider} from '@microsoft/mgt-msal2-provider'; 6 | import {TeamsProvider} from '@microsoft/mgt-teams-provider'; 7 | import { Providers } from '@microsoft/mgt-element'; 8 | 9 | import * as MicrosoftTeams from "@microsoft/teams-js"; 10 | 11 | TeamsProvider.microsoftTeamsLib = MicrosoftTeams; 12 | 13 | let provider; 14 | const clientId = 'eb3b6c7d-41fa-4607-b010-3ddd0a1f071b'; 15 | 16 | const scopes = [ 17 | 'user.read', 18 | 'people.read', 19 | 'user.readbasic.all', 20 | 'contacts.read', 21 | 'calendars.read', 22 | 'Presence.Read.All', 23 | 'Presence.Read' 24 | ] 25 | 26 | if (TeamsProvider.isAvailable) { 27 | provider = new TeamsProvider({ 28 | clientId, 29 | scopes, 30 | authPopupUrl: '/teamsauth' 31 | }) 32 | } else { 33 | provider = new Msal2Provider({ 34 | clientId, 35 | scopes, 36 | redirectUri: window.location.origin 37 | }); 38 | } 39 | 40 | Providers.globalProvider = provider; 41 | 42 | 43 | ReactDOM.render( 44 | 45 | 46 | , 47 | document.getElementById('root') 48 | ); 49 | 50 | // serviceWorker.register(); -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | Moderator 24 | 25 | 26 | 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /src/images/move.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.publish/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.6/MicrosoftTeams.schema.json", 3 | "manifestVersion": "1.6", 4 | "version": "{version}", 5 | "showLoadingIndicator": false, 6 | "id": "05569668-98ea-434f-9294-9767ec13a513", 7 | "packageName": "com.moderator", 8 | "developer": { 9 | "name": "Contoso", 10 | "websiteUrl": "{baseUrl0}/", 11 | "privacyUrl": "{baseUrl0}/", 12 | "termsOfUseUrl": "{baseUrl0}/" 13 | }, 14 | "icons": { 15 | "color": "color.png", 16 | "outline": "outline.png" 17 | }, 18 | "name": { 19 | "short": "{appname}", 20 | "full": "{fullappname}" 21 | }, 22 | "description": { 23 | "short": "Moderate large Microsoft Teams meetings", 24 | "full": "The app helps you moderate large Microsoft Teams meeting with small group breakouts." 25 | }, 26 | "accentColor": "#0078D4", 27 | "configurableTabs": [ 28 | { 29 | "configurationUrl": "{baseUrl0}/teamsconfig", 30 | "canUpdateConfiguration": false, 31 | "scopes": [ 32 | "groupchat" 33 | ] 34 | } 35 | ], 36 | "staticTabs": [ 37 | { 38 | "entityId": "moderator", 39 | "name": "Moderator", 40 | "contentUrl": "{baseUrl0}/", 41 | "scopes": [ 42 | "personal" 43 | ] 44 | } 45 | ], 46 | "permissions": [ 47 | "identity", 48 | "messageTeamMembers" 49 | ], 50 | "validDomains": [] 51 | } -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | 2 | export const isToday = (someDate) => { 3 | const today = new Date() 4 | return someDate.getDate() === today.getDate() && 5 | someDate.getMonth() === today.getMonth() && 6 | someDate.getFullYear() === today.getFullYear() 7 | } 8 | 9 | export const isTomorrow = (someDate) => { 10 | const today = new Date() 11 | today.setDate(today.getDate() + 1); 12 | return someDate.getDate() === today.getDate() && 13 | someDate.getMonth() === today.getMonth() && 14 | someDate.getFullYear() === today.getFullYear() 15 | } 16 | 17 | export const getDateHeader = (someDate) => { 18 | let weekday = new Intl.DateTimeFormat('en', { weekday: 'short' }).format(someDate) 19 | 20 | if (isToday(someDate)) { 21 | weekday = 'Today'; 22 | } else if (isTomorrow(someDate)) { 23 | weekday = 'Tomorrow'; 24 | } 25 | 26 | const month = new Intl.DateTimeFormat('en', { month: 'short' }).format(someDate) 27 | const day = new Intl.DateTimeFormat('en', { day: 'numeric' }).format(someDate) 28 | 29 | return `${weekday}, ${month} ${day}`; 30 | } 31 | 32 | export const getDuration = (start: Date, end: Date) => { 33 | const diff = Math.abs(end.valueOf() - start.valueOf()); 34 | 35 | let minutes = diff / 1000 / 60; 36 | 37 | if (minutes < 60) { 38 | return `${minutes} minutes` 39 | } 40 | 41 | const hour = minutes / 60; 42 | 43 | return `${hour} hr` + (hour > 1 ? 's' : ''); 44 | }; 45 | 46 | export const getFormatedTime = (date: Date) => { 47 | 48 | date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); 49 | return new Intl.DateTimeFormat('en', { hour: 'numeric', minute: 'numeric' }).format(date) 50 | }; -------------------------------------------------------------------------------- /src/App/TeamsTabConfig/TabConfig.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import * as MicrosoftTeams from "@microsoft/teams-js"; 4 | import { Icon } from '@fluentui/react'; 5 | 6 | import { GraphEvent } from '../../utils/types'; 7 | import { getEvents } from '../../utils/graph.events'; 8 | 9 | export const TabConfig = () => { 10 | const [message, setMessage] = useState('Looking for event...'); 11 | 12 | useEffect(() => { 13 | MicrosoftTeams.getContext(async (context) => { 14 | const chatId = context.chatId; 15 | const events : GraphEvent[] = await getEvents(); 16 | 17 | if (events) { 18 | let filteredEvents = events.filter(e => e.onlineMeeting && unescape(e.onlineMeeting.joinUrl).includes(chatId)); 19 | 20 | if (filteredEvents.length > 0) { 21 | setMessage(`Found event ${filteredEvents[0].subject}. Click "Save" to add tab!`) 22 | MicrosoftTeams.settings.setSettings({"entityId": 'moderator', "contentUrl": `${window.location.origin}/events/${filteredEvents[0].id}`}); 23 | MicrosoftTeams.settings.setValidityState(true); 24 | } else { 25 | setMessage('Event not found :(. This chat might not be a meeting chat.') 26 | } 27 | } else { 28 | setMessage('No events found in calendar or there was error getting events...') 29 | } 30 | }); 31 | }, []) 32 | 33 | return ( 34 |
35 | 36 |

Setting up the Moderator tab

37 | {message} 38 |
39 | ); 40 | } -------------------------------------------------------------------------------- /src/App/AgendaView/AgendaView.css: -------------------------------------------------------------------------------- 1 | .AgendaView { 2 | display: flex; 3 | flex-direction: column; 4 | max-width: 720px; 5 | margin: 0 auto; 6 | } 7 | 8 | .AgendaView .Card { 9 | margin: 0 20px 10 | } 11 | 12 | .AgendaView mgt-agenda { 13 | margin-top: -20px 14 | } 15 | 16 | .AgendaView .HelloView { 17 | align-self: center; 18 | font-weight: normal; 19 | line-height: 28px; 20 | margin: 40px 0; 21 | text-align: center; 22 | } 23 | 24 | .AgendaView .HelloView .Title { 25 | font-size: 22px; 26 | } 27 | 28 | .AgendaHeader { 29 | margin: 30px 0 10px 0; 30 | } 31 | 32 | .AgendaEvent { 33 | height: 100px; 34 | background: #DEECF9; 35 | margin-bottom: 10px; 36 | display: flex; 37 | align-items: center; 38 | box-shadow: 0px 0px 14.4px rgba(0, 0, 0, 0.13), 0px 0px 3.6px rgba(0, 0, 0, 0.1); 39 | margin-left: -40px; 40 | margin-right: -40px; 41 | } 42 | 43 | .AgendaEvent.OfflineEvent { 44 | background: #F3F2F1; 45 | box-shadow: none; 46 | margin-right: 0px; 47 | margin-left: 0px; 48 | } 49 | 50 | 51 | 52 | .AgendaEvent .AgendaEventDetails { 53 | margin-left: 10px; 54 | margin-top:5px; 55 | flex-grow: 1; 56 | flex-shrink: 0; 57 | } 58 | 59 | .AgendaEvent.OfflineEvent .AgendaEventDetails { 60 | opacity: 0.3; 61 | } 62 | 63 | .AgendaEvent .AgendaEventDetails .EventTime { 64 | font-weight: 600; 65 | font-size: 12px; 66 | line-height: 16px; 67 | } 68 | 69 | .AgendaEvent .AgendaEventDetails .EventSubject { 70 | font-weight: 350; 71 | font-size: 19px; 72 | line-height: 25px; 73 | opacity: 0.9; 74 | } 75 | 76 | .AgendaEvent .AgendaEventDetails .EventAttendees { 77 | margin-left: -10px; 78 | } 79 | 80 | .AgendaEvent .AgendaActions { 81 | margin-right: 20px; 82 | } 83 | 84 | .AgendaEvent .AgendaActions > button { 85 | margin-right: 20px; 86 | } 87 | 88 | @media all and (max-width: 600px) { 89 | .AgendaActions { 90 | display: flex; 91 | flex-direction: column; 92 | } 93 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moderator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fluentui/react": "^8.14.7", 7 | "@microsoft/mgt-element": "2.3.0-preview.16b6ac0", 8 | "@microsoft/mgt-msal2-provider": "2.3.0-preview.16b6ac0", 9 | "@microsoft/mgt-teams-provider": "2.3.0-preview.16b6ac0", 10 | "@microsoft/mgt-react": "2.3.0-preview.16b6ac0", 11 | "@microsoft/microsoft-graph-client": "^2.2.1", 12 | "@microsoft/microsoft-graph-types": "^1.37.0", 13 | "@microsoft/teams-js": "^1.8.0", 14 | "@pwabuilder/pwainstall": "^1.6.7", 15 | "@testing-library/jest-dom": "^4.2.4", 16 | "@testing-library/react": "^9.3.2", 17 | "@testing-library/user-event": "^7.1.2", 18 | "@types/jest": "^24.0.0", 19 | "@types/node": "^12.0.0", 20 | "@types/react": "^17.0.5", 21 | "@types/react-dom": "^17.0.5", 22 | "@types/react-router-dom": "^5.1.7", 23 | "@webcomponents/webcomponentsjs": "^2.4.3", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "react-router-dom": "^5.2.0", 27 | "react-scripts": "4.0.3", 28 | "typescript": "~3.7.2", 29 | "vendor-copy": "^2.0.0" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject", 36 | "postinstall": "vendor-copy", 37 | "ngrok": "ngrok http 3000 -subdomain=moderator" 38 | }, 39 | "eslintConfig": { 40 | "extends": "react-app" 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "vendorCopy": [ 55 | { 56 | "from": "node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js", 57 | "to": "public/vendor/custom-elements-es5-adapter.js" 58 | }, 59 | { 60 | "from": "node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js", 61 | "to": "public/vendor/webcomponents-bundle.js" 62 | } 63 | ], 64 | "engines": { 65 | "node": ">=10.0 <11.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/App/EventView/EventDetailsView/EventDetailsView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 4 | import { Person, PersonCardInteraction, PersonViewType } from '@microsoft/mgt-react'; 5 | import { ActionButton } from '@fluentui/react'; 6 | 7 | import { GraphEvent } from '../../../utils/types'; 8 | import './EventDetailsView.css' 9 | import { openTeamsUrl } from '../../../utils/helpers'; 10 | 11 | export const EventDetailsView = (props: {event: GraphEvent, participants: MicrosoftGraph.User[]}) => { 12 | 13 | const handleJoin = () => { 14 | openTeamsUrl(props.event.onlineMeeting.joinUrl); 15 | } 16 | 17 | return
18 |
19 |
Details
20 |
21 |
22 |
Subject
23 |
{props.event.subject}
24 |
25 |
26 |
Date
27 |
TODO
28 |
29 |
30 |
Time
31 |
TODO
32 |
33 |
34 |
Body
35 |
{props.event.bodyPreview}
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 | 45 |
46 |
47 |
Participants
48 |
49 | {props.participants.map((p,i) => ( 50 |
51 | 52 |
53 | ))} 54 |
55 |
56 |
57 | } -------------------------------------------------------------------------------- /src/utils/graph.events.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from "./graph"; 2 | 3 | const extensionName = 'com.moderatorTest' 4 | 5 | export const getEventFromId = async (id: string) => { 6 | try { 7 | return await getClient() 8 | .api(`me/calendar/events/${id}`) 9 | .select([ 10 | 'id', 11 | 'subject', 12 | 'bodyPreview', 13 | 'onlineMeeting', 14 | 'end', 15 | 'start', 16 | 'isOnlineMeeting', 17 | 'onlineMeetingProvider']) 18 | .expand(`extensions($filter=id eq '${extensionName}')`) 19 | .get(); 20 | } catch (e) { 21 | return null; 22 | } 23 | } 24 | 25 | export const getEventExtension = async (eventId: string) => { 26 | try { 27 | let extension = await getClient().api(`me/events/${eventId}/extensions/${extensionName}`).get(); 28 | return extension; 29 | } catch (e) { 30 | if (e.code === 'ErrorItemNotFound') { 31 | return await createEventExtension(eventId); 32 | } 33 | return null; 34 | } 35 | } 36 | 37 | export const createEventExtension = async (eventId: string) => { 38 | try { 39 | let extension = await getClient().api(`me/events/${eventId}/extensions`).post({ 40 | "@odata.type": "microsoft.graph.openTypeExtension", 41 | "extensionName": extensionName, 42 | "breakouts": "" 43 | }); 44 | return extension; 45 | } catch (e) { 46 | return null; 47 | } 48 | } 49 | 50 | export const updateEventExtension = async (eventId: string, breakouts: any) => { 51 | const content = { 52 | "@odata.type": "microsoft.graph.openTypeExtension", 53 | "breakouts": !!breakouts ? JSON.stringify(breakouts) : '' 54 | }; 55 | 56 | try { 57 | let extension = await getClient().api(`me/events/${eventId}/extensions/${extensionName}`).patch(content); 58 | return extension; 59 | } catch (e) { 60 | return null; 61 | } 62 | } 63 | 64 | export const getEvents = async (days = 3) => { 65 | const startDate = new Date(); 66 | startDate.setHours(0, 0, 0, 0); 67 | 68 | const endDate = new Date(startDate.getTime()); 69 | endDate.setDate(startDate.getDate() + days); // next 3 days 70 | 71 | const sdt = `startdatetime=${startDate.toISOString()}`; 72 | const edt = `enddatetime=${endDate.toISOString()}`; 73 | 74 | try { 75 | let response = await getClient()?.api(`me/calendarview?${sdt}&${edt}`).get(); 76 | return response.value; 77 | } catch (e) { 78 | return null; 79 | } 80 | } -------------------------------------------------------------------------------- /src/App/App.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | body { 4 | margin: 0px; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | background: #F4F4F4; 11 | } 12 | 13 | 14 | 15 | .App-logo { 16 | height: 40vmin; 17 | pointer-events: none; 18 | } 19 | 20 | 21 | .App-header { 22 | background-color: #0078D4; 23 | display: flex; 24 | flex-direction: row; 25 | align-items: center; 26 | font-size: 16px; 27 | color: white; 28 | height: 48px; 29 | } 30 | 31 | .Event-title { 32 | flex-grow: 1; 33 | } 34 | 35 | .App-title { 36 | margin-left: 20px; 37 | cursor: pointer; 38 | } 39 | 40 | .AboutButton { 41 | color: white; 42 | width: 20px; 43 | height: 20px; 44 | } 45 | 46 | .AboutButton:hover { 47 | border-radius: 50%; 48 | } 49 | 50 | .App-content { 51 | padding: 0 20px 20px 20px; 52 | } 53 | 54 | .App-header mgt-login { 55 | --button-color: white; 56 | --background-color--hover: white; 57 | --color-hover: rgb(218, 218, 218); 58 | margin: 0 12px 0 0; 59 | } 60 | 61 | .Card { 62 | background: #FFFFFF; 63 | box-shadow: 0px 6.4px 14.4px rgba(0, 0, 0, 0.13), 0px 1.2px 3.6px rgba(0, 0, 0, 0.1); 64 | border-radius: 2px; 65 | padding: 20px; 66 | } 67 | 68 | .Card .CardTitle { 69 | font-weight: normal; 70 | font-size: 22px; 71 | line-height: 28px; 72 | } 73 | 74 | .welcome { 75 | margin: 40px 20px 20px 20px; 76 | min-height: 40em; 77 | height: calc(100vh - 140px); 78 | display: flex; 79 | flex-direction: column; 80 | justify-content: center; 81 | align-items: center; 82 | padding: 0px 40px; 83 | color: #201F1E; 84 | min-width: 200px; 85 | } 86 | 87 | .welcome svg { 88 | max-width: 100%; 89 | } 90 | 91 | .welcome h1 { 92 | font-weight: 200; 93 | margin: 0.5em 0; 94 | line-height: 48px; 95 | font-size: 48px; 96 | } 97 | 98 | .app-description { 99 | text-align: center; 100 | font-size: 12px; 101 | line-height: 20px; 102 | max-width: 400px; 103 | } 104 | 105 | .App-content .welcome mgt-login { 106 | background: #0078D4; 107 | border-radius: 2px; 108 | margin: 2em 0 0 0; 109 | --button-color: white; 110 | --background-color--hover: rgb(16, 110, 190); 111 | --color-hover: #ffffff; 112 | } 113 | 114 | .HelpIcon { 115 | font-weight: bold; 116 | font-size: 12px; 117 | /* margin-right: -2em; */ 118 | background: #ffffff; 119 | border-radius: 50%; 120 | margin-right: 20px; 121 | } 122 | 123 | .HelpIcon .ms-Icon { 124 | color: #0078D4; 125 | font-weight: bold; 126 | font-size: 12px; 127 | } 128 | 129 | .InstallButton { 130 | margin-right: 40px; 131 | } -------------------------------------------------------------------------------- /src/App/EventView/EventView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import { Pivot, PivotItem, MessageBar, MessageBarType, Spinner, SpinnerSize } from '@fluentui/react'; 4 | import { BreakoutView } from './BreakoutView/BreakoutView'; 5 | import { useParams } from 'react-router-dom'; 6 | import { EventDetailsView } from './EventDetailsView/EventDetailsView'; 7 | import { getEventFromId } from '../../utils/graph.events'; 8 | import { getChatParticipantsForOnlineMeeting } from '../../utils/graph.onlineMeetings'; 9 | import { GraphEvent } from '../../utils/types'; 10 | 11 | export function EventView() { 12 | let { id } = useParams(); 13 | 14 | const [isLoading, setIsLoading] = useState(true); 15 | const [error, setError] = useState(null); 16 | 17 | const [event, setEvent] = useState(null); 18 | const [participants, setParticipants] = useState(null); 19 | 20 | useEffect(() => { 21 | (async () => { 22 | if (isLoading) { 23 | 24 | const event: GraphEvent = await getEventFromId(id); 25 | 26 | if (!event) { 27 | setError('Event not found!'); 28 | return; 29 | } 30 | 31 | if (event && (!event.isOnlineMeeting || event.onlineMeetingProvider !== 'teamsForBusiness')) { 32 | setError('Event is not a Teams meeting'); 33 | return; 34 | } 35 | 36 | let attendees = await getChatParticipantsForOnlineMeeting(event.onlineMeeting.joinUrl); 37 | 38 | // chatparticipant id is different that org id 39 | // mgt components expect id to be the actual user id 40 | attendees = attendees.map(a => {return {...a, id: a.userId}}) 41 | 42 | setEvent(event); 43 | setParticipants(attendees); 44 | setIsLoading(false); 45 | } 46 | })(); 47 | }, []); 48 | 49 | if (error) { 50 | return {error}; 51 | } 52 | 53 | if (isLoading) { 54 | return ( 55 |
56 | 58 |
) 59 | ; 60 | } 61 | 62 | return ( 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | } 73 | 74 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/App/AgendaView/AgendaView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useHistory } from "react-router-dom"; 4 | import { ActionButton } from '@fluentui/react'; 5 | import { Providers } from '@microsoft/mgt-element'; 6 | import { Agenda, MgtTemplateProps, People, PersonCardInteraction } from '@microsoft/mgt-react'; 7 | 8 | import { getDateHeader, getFormatedTime } from '../../utils/date'; 9 | import { GraphEvent } from '../../utils/types'; 10 | import './AgendaView.css'; 11 | import { openTeamsUrl } from '../../utils/helpers'; 12 | 13 | export function AgendaView() { 14 | 15 | const history = useHistory(); 16 | 17 | const handleEventClick = async (event: GraphEvent) => { 18 | 19 | let token = await Providers.globalProvider.getAccessTokenForScopes( 20 | 'Calendars.ReadWrite', 21 | 'Chat.ReadWrite', 22 | 'Group.ReadWrite.All', 23 | 'OnlineMeetings.ReadWrite', 24 | 'GroupMember.ReadWrite.All',); 25 | 26 | if (token) { 27 | history.push(`/events/${event.id}`); 28 | } 29 | } 30 | 31 | return ( 32 |
33 | 34 |
35 |
Hello!
36 |
Select a meeting to start moderating!
37 |
38 | 39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | ); 47 | } 48 | 49 | let AgendaHeader = (props: MgtTemplateProps) => { 50 | let date = new Date(props.dataContext.header); 51 | return
{getDateHeader(date)}
52 | } 53 | 54 | let AgendaEvent = (props: MgtTemplateProps & {onClick: (event: GraphEvent) => void}) => { 55 | 56 | const event: GraphEvent = props.dataContext.event; 57 | const start = new Date(event.start.dateTime) 58 | const end = new Date(event.end.dateTime); 59 | 60 | const handleJoin = () => { 61 | openTeamsUrl(event.onlineMeeting.joinUrl); 62 | } 63 | 64 | return ( 65 |
66 |
67 |
{`${getFormatedTime(start)} - ${getFormatedTime(end)}`}
68 |
{event.subject}
69 |
70 | a.emailAddress.address)} 72 | personCardInteraction={event.isOnlineMeeting ? PersonCardInteraction.hover : PersonCardInteraction.none} 73 | showPresence={event.isOnlineMeeting}> 74 | 75 |
76 |
77 | {event.isOnlineMeeting && 78 |
79 | props.onClick(event)}/> 84 | 89 |
90 | } 91 |
92 | ) 93 | } -------------------------------------------------------------------------------- /src/App/EventView/BreakoutView/BreakoutsCreatorView/BreakoutsCreatorView.css: -------------------------------------------------------------------------------- 1 | .BreakoutsCreatorView { 2 | display: flex; 3 | width: 100%; 4 | height: calc(100vh - 140px); 5 | } 6 | 7 | .BreakoutsCreatorView .Breakouts { 8 | margin-right: 20px; 9 | margin-top: 0px; 10 | background: white; 11 | flex-grow: 1; 12 | overflow-y: auto; 13 | } 14 | 15 | 16 | .BreakoutsCreatorView .Slider { 17 | width: 182px; 18 | margin-top: 32px; 19 | margin-bottom: 42px; 20 | } 21 | 22 | .BreakoutsCreatorView .Slider .ms-Slider-slideBox { 23 | padding: 0;; 24 | } 25 | 26 | .GroupsCreator { 27 | margin-top: 20px; 28 | } 29 | 30 | .GroupsCreatorTitle { 31 | font-size: 20px; 32 | } 33 | 34 | .GroupSizeInput { 35 | width: 250px; 36 | margin-bottom: 20px; 37 | } 38 | 39 | 40 | .Breakouts .Groups { 41 | margin-right: -20px; 42 | } 43 | 44 | .Groups { 45 | display: flex; 46 | flex-wrap: wrap; 47 | } 48 | 49 | .GroupView { 50 | margin-right: 20px; 51 | margin-bottom: 30px; 52 | width: 200px; 53 | display: flex; 54 | flex-direction: column; 55 | } 56 | 57 | .GroupView .GroupTitle { 58 | font-size: 18px; 59 | line-height: 28px; 60 | color: #201F1E; 61 | opacity: 0.65; 62 | margin-bottom: 5px; 63 | } 64 | 65 | .GroupView .GroupPeople { 66 | box-shadow: 0px 6.4px 14.4px rgba(0, 0, 0, 0.13), 0px 1.2px 3.6px rgba(0, 0, 0, 0.1); 67 | border-radius: 2px; 68 | } 69 | 70 | .GroupView .GroupPerson { 71 | height: 40px; 72 | padding: 10px; 73 | border: 1px solid #f2f2f2; 74 | display: flex; 75 | flex-direction: row; 76 | flex-wrap: nowrap; 77 | align-items: center; 78 | } 79 | 80 | .GroupView .GroupPerson .GroupPersonMoveIcon { 81 | font-size: 14px; 82 | height: 14px; 83 | width: 14px; 84 | align-self: center; 85 | margin-right: 8px; 86 | flex-shrink: 0; 87 | } 88 | 89 | .GroupView .GroupPerson mgt-person { 90 | --avatar-size: 40px; 91 | flex-shrink: 1; 92 | overflow: hidden; 93 | } 94 | 95 | .BreakoutsCreatorView .Moderators { 96 | width: 300px; 97 | background: white; 98 | display: flex; 99 | flex-direction: column; 100 | } 101 | 102 | .ModeratorsList { 103 | width: 300px; 104 | margin: 0 -20px; 105 | padding: 10px 20px; 106 | overflow-y: auto; 107 | flex-grow: 1; 108 | } 109 | 110 | .Moderators .ModeratorPerson { 111 | margin-bottom: 10px; 112 | padding: 10px; 113 | display: flex; 114 | flex-wrap: nowrap; 115 | align-items: center; 116 | } 117 | 118 | .Moderators .ModeratorPerson mgt-person { 119 | flex-grow: 1; 120 | overflow: hidden; 121 | } 122 | 123 | .Moderators .ModeratorPerson .ModeratorCloseIconButton .ms-Icon { 124 | font-size: 13px; 125 | } 126 | 127 | @media all and (max-width: 876px) { 128 | .BreakoutsCreatorView { 129 | flex-direction: column; 130 | width: 100%; 131 | height: unset; 132 | } 133 | 134 | .BreakoutsCreatorView .Breakouts { 135 | margin-right: 0; 136 | overflow-y: hidden; 137 | margin-bottom: 20px; 138 | } 139 | 140 | .BreakoutsCreatorView .Moderators { 141 | width: unset; 142 | min-height: 400px; 143 | } 144 | 145 | .ModeratorsList { 146 | width: unset; 147 | /* padding: 10px 20px; */ 148 | overflow-y: hidden; 149 | } 150 | } -------------------------------------------------------------------------------- /src/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './App.css'; 3 | 4 | import { Providers, ProviderState, TeamsHelper } from '@microsoft/mgt-element'; 5 | import { Switch, Route, BrowserRouter, useHistory } from "react-router-dom"; 6 | import { MgtTemplateProps, Login, Person } from '@microsoft/mgt-react'; 7 | import { initializeIcons, IconButton, Spinner, SpinnerSize } from '@fluentui/react'; 8 | import { wrapWc } from 'wc-react'; 9 | import '@pwabuilder/pwainstall'; 10 | 11 | import { AgendaView } from './AgendaView/AgendaView'; 12 | import { EventView } from './EventView/EventView'; 13 | import { TeamsAuth } from './TeamsAuth/TeamsAuth'; 14 | import { TabConfig } from './TeamsTabConfig/TabConfig'; 15 | import {ReactComponent as BicycleImage} from '../images/tandem-bicycle.svg'; 16 | 17 | function App() { 18 | 19 | initializeIcons(); 20 | 21 | const [authState, setAuthState] = useState(Providers.globalProvider.state); 22 | const PwaInstall = wrapWc('pwa-install'); 23 | 24 | if (window.location.pathname.startsWith('/teamsauth')) { 25 | return 26 | } 27 | 28 | Providers.onProviderUpdated(() => { 29 | setAuthState(Providers.globalProvider.state) 30 | }) 31 | 32 | return ( 33 | 34 | 35 |
36 | {!TeamsHelper.isAvailable && 37 |
38 | 39 |
40 | 41 |
42 |
43 | window.open("https://github.com/microsoftgraph/meeting-moderator-sample", '_blank')}> 47 |
48 | {authState !== ProviderState.SignedOut && 49 | 50 | 51 | 52 | } 53 |
54 | } 55 |
56 | {authState === ProviderState.SignedIn && } 57 | {authState === ProviderState.SignedOut && } 58 | {authState === ProviderState.Loading && } 59 |
60 |
61 |
62 | ); 63 | } 64 | 65 | const MainContent = () => { 66 | return ( 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ) 79 | } 80 | 81 | const SignedInButtonContent = (props: MgtTemplateProps) => { 82 | const {personDetails, personImage} = props.dataContext; 83 | 84 | return
85 | 86 |
87 | } 88 | 89 | const HeaderTitle = () => { 90 | 91 | const history = useHistory(); 92 | 93 | const handleTitleClick = () => { 94 | if (history.location.pathname !== '/') { 95 | history.push(`/`); 96 | } 97 | } 98 | 99 | return
100 |
Moderator
101 |
; 102 | } 103 | 104 | const LoginPage = () => { 105 | return
106 | 107 |

Moderator

108 |
109 | Select an online meeting to start moderating. As moderator, you can split your meeting participants into breakout rooms. 110 |
111 | 112 |
113 | } 114 | 115 | export default App; 116 | -------------------------------------------------------------------------------- /src/utils/graph.onlineMeetings.ts: -------------------------------------------------------------------------------- 1 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 2 | import {BatchRequestStep, BatchRequestContent} from '@microsoft/microsoft-graph-client' 3 | import { getClient } from './graph'; 4 | 5 | export const getChatIdFromOnlineMeeting = (onlineMeetingUri: string) => { 6 | const match = onlineMeetingUri.match(/meetup-join\/(.*)\//); 7 | if (match.length > 1) { 8 | return match[1]; 9 | } 10 | 11 | return null; 12 | } 13 | 14 | export const getChatParticipantsForOnlineMeeting = async (onlineMeetingUri: string) => { 15 | try { 16 | let chatId = getChatIdFromOnlineMeeting(onlineMeetingUri); 17 | const members = await getClient()?.api(`me/chats/${chatId}/members`).version('beta').get(); 18 | if (members && members.value) { 19 | return members.value; 20 | } 21 | } catch (e) { 22 | return null; 23 | } 24 | 25 | return null; 26 | } 27 | 28 | export const createOnlineMeeting = async (startDateTime: string, endDateTime: string, subject: string) => { 29 | try { 30 | return await getClient().api('/me/onlineMeetings').post({ 31 | startDateTime: startDateTime + '-00:00', 32 | endDateTime: endDateTime + '-00:00', 33 | subject 34 | }); 35 | } catch (e) { 36 | return null; 37 | } 38 | } 39 | 40 | export const createOnlineMeetingsForGroups = async (startDateTime: string, endDateTime: string, numOfGroups: number) => { 41 | 42 | let requests: BatchRequestStep[] = []; 43 | 44 | for (let i = 0; i < numOfGroups; i++) { 45 | 46 | const content = { 47 | startDateTime: startDateTime + '-00:00', 48 | endDateTime: endDateTime + '-00:00', 49 | subject: `Group ${i+1} breakout` 50 | } 51 | 52 | const request = new Request(`/me/onlineMeetings`, { 53 | method: 'POST', 54 | body: JSON.stringify(content), 55 | headers: { 56 | 'Content-Type': 'application/json' 57 | } 58 | }); 59 | 60 | requests.push({ 61 | id: i.toString(), 62 | request 63 | }) 64 | } 65 | 66 | let responses = []; 67 | 68 | while (requests.length > 0) { 69 | 70 | // batch supports 20 requests at a time 71 | const currentRequests = requests.splice(0, 20); 72 | const content = await (new BatchRequestContent(currentRequests)).getContent() 73 | 74 | try { 75 | const response = await getClient().api('/$batch').post(content); 76 | 77 | for (const r of response.responses) { 78 | responses[r.id] = r.body; 79 | } 80 | } catch (e) { 81 | 82 | } 83 | } 84 | 85 | return responses; 86 | } 87 | 88 | export const sendMessageToOnlineMeeting = async (onlineMeetingUri: string, message: string, mentioned?: MicrosoftGraph.User[]) => { 89 | try { 90 | let chatId = getChatIdFromOnlineMeeting(onlineMeetingUri); 91 | const messageObj: any = {}; 92 | 93 | if (mentioned) { 94 | message += '

' 95 | let mentions = []; 96 | for (let i = 0; i < mentioned.length; i++) { 97 | const person = mentioned[i]; 98 | message += ` ${person.displayName} `; 99 | mentions.push({ 100 | "id": i, 101 | "mentionText": person.displayName, 102 | "mentioned": { 103 | "user": { 104 | "displayName": person.displayName, 105 | "id": person.id, 106 | "userIdentityType": "aadUser" 107 | } 108 | } 109 | }) 110 | } 111 | 112 | messageObj.mentions = mentions; 113 | } 114 | 115 | messageObj.body = { 116 | content: message, 117 | contentType: 'html' 118 | } 119 | 120 | 121 | await getClient()?.api(`/chats/${chatId}/messages`).version('beta').post(messageObj); 122 | } catch (e) { 123 | return null; 124 | } 125 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft 365 and Graph Code Sample - Meeting Moderator 2 | 3 | ## Overview 4 | 5 | This sample demonstrates usage of Microsoft Graph to implement small group breakouts in a large Microsoft Teams meeting. 6 | 7 | The sample is a web application built with React and Microsoft Graph Toolkit. The app can run as a web app in the browser, a personal or group tab app in Microsoft Teams, or a Progressive Web app on most modern desktop and mobile operating systems. 8 | 9 | [Watch the live stream where we showed how the app was built](https://www.youtube.com/playlist?list=PLWZJrkeLOrbYL7tFQJ-HY6Q9FZGmqSldH) 10 | 11 | ## Prerequisites 12 | 13 | * [NodeJS](https://nodejs.org/en/download/) 14 | * A code editor - we recommend [VS Code](https://code.visualstudio.com/) 15 | * Office 365 Tenant and a Global Admin account - [if you don't have one, get one for free here](https://docs.microsoft.com/en-us/office/developer-program/microsoft-365-developer-program-get-started) 16 | 17 | ## Run the sample 18 | 19 | In a terminal: 20 | 21 | ```bash 22 | git clone https://github.com/microsoftgraph/meeting-moderator-sample 23 | 24 | cd meeting-moderator-sample 25 | 26 | npm install 27 | 28 | npm start 29 | ``` 30 | 31 | The app will launch in your browser at `http://localhost:3000`. Sign in with your tenant credentials and consent to the permissions (make sure the user has ability to Admin consent). 32 | 33 | The app will load the current signed in user's calendar with the option to moderate individual events. 34 | 35 | > Note: Only Microsoft Teams online meetings will be available for moderation. All other events will be grayed out. 36 | 37 | ## Create an Azure Active Directory (AAD) application 38 | 39 | The sample already contains a client id for an AAD application to allow you to get started immediately. However, we recommend creating your own so you have full control of the application. 40 | 41 | To create your own AAD app and client id, [follow the instructions in this blog post](https://developer.microsoft.com/microsoft-365/blogs/a-lap-around-microsoft-graph-toolkit-day-2-zero-to-hero/), under **Register your application**. 42 | 43 | This step is required for the next section. The client id is defined in `src\index.tsx` 44 | 45 | ## Installing the sample as a Teams application 46 | 47 | This sample is also a Microsoft Teams tab application and you can install it in your instance by following these instructions. 48 | 49 | ### 1. Run ngrok 50 | 51 | To install it in your Teams environment, Microsoft Teams requires the app to be publicly accessible using HTTPS endpoint. To accomplish this, you can use `ngrok`, a tunneling software, which creates an externally addressable URL for a port you open locally on your machine: 52 | 53 | 1. Install ngrok 54 | ```bash 55 | npm install -g ngrok 56 | ``` 57 | 58 | 1. Ensure the app is running locally on `http://localhost:3000`. Start ngrok and attach it to port 3000 59 | 60 | ```bash 61 | ngrok http 3000 62 | ``` 63 | 64 | This will generate a public url (similar to `https://455709c1.ngrok.io`) that you can use to access the app. You will use this url in your Teams manifest. 65 | 66 | ### 2. Add ngrok url to your AAD application 67 | 68 | Now that ngrok is running, you need to add this url to your AAD application redirect urls. If you have not created a new AAD application, make sure you do that first before continuing (see section above). 69 | 70 | ### 3. Update Teams manifest and install application 71 | 72 | To update the manifest, you will need to install the [Microsoft Team Extension](https://aka.ms/teams-toolkit) in VS code. Once you've installed the extension, open `.publish/Development.env` and replace the `baseUrl0` with your ngrok url. Save the file. 73 | 74 | Once the file is saved, the `Development.zip` package will be automatically updated. You can now use this package to install the application in Microsoft Teams: 75 | 76 | 1. In Microsoft Teams, click on `Apps` in the lower left corner 77 | 78 | 2. Click on `Upload a custom app`. This will open the file picker. Select `Development.zip` to install the application. 79 | 80 | ## Using the Moderator Bot 81 | 82 | This sample also includes a helpful bot for helping the moderator keep track of questions from the attendees. See the `bot` branch for description and instructions on deploying and running the bot in your Azure Subscription. 83 | 84 | 85 | ## Useful Links 86 | - Microsoft Graph Dev Portal https://graph.developer.com/ 87 | - Graph Explorer https://aka.ms/GE 88 | - Microsoft Graph Toolkit https://aka.ms/mgt 89 | - Microsoft Graph Toolkit Blog Series https://aka.ms/mgtLap 90 | - Microsoft Teams Dev Portal https://developer.microsoft.com/en-us/microsoft-teams 91 | -------------------------------------------------------------------------------- /src/App/EventView/BreakoutView/BreakoutsCreatorView/BreakoutsCreatorView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 4 | import { PrimaryButton, IconButton, Slider } from '@fluentui/react'; 5 | import { Person, PeoplePicker, PersonCardInteraction, PersonViewType } from '@microsoft/mgt-react'; 6 | 7 | import {ReactComponent as MoveIcon} from '../../../../images/move.svg'; 8 | import './BreakoutsCreatorView.css' 9 | 10 | export type BreakoutCreatorViewProps = { 11 | participants: MicrosoftGraph.User[], 12 | moderators: MicrosoftGraph.User[], 13 | onModeratorsAdded: Function, 14 | onModeratorsRemoved: Function, 15 | currentSignedInUser: MicrosoftGraph.User, 16 | onCreateClick: (groups: MicrosoftGraph.User[][]) => void, 17 | } 18 | 19 | export const BreakoutsCreatorView = (props: BreakoutCreatorViewProps) => { 20 | 21 | const [groupSize, setGroupSize] = useState(5); 22 | const [groups, setGroups] = useState(null); 23 | 24 | useEffect(() => { 25 | setGroups(generateGroups(props.participants, groupSize)); 26 | }, [groupSize, props.participants]); 27 | 28 | const handleSelectionChanged = (e) => { 29 | let person = e.detail[0]; 30 | e.target.selectedPeople = []; 31 | props.onModeratorsAdded(person); 32 | }; 33 | 34 | const handleRemoveClicked = (person) => { 35 | props.onModeratorsRemoved(person) 36 | } 37 | 38 | return
39 |
40 |
Create Breakouts
41 | setGroupSize(value)} 50 | snapToStep 51 | /> 52 |
53 | {groups && groups.map((g, i) => ())} 54 |
55 |
56 | props.onCreateClick(groups)} /> 57 |
58 | 59 |
60 |
61 |
Moderators
62 |
63 | {props.moderators.map((p, i) => 64 |
65 | 66 | {props.currentSignedInUser.id === p.id ? '' : ( 67 |
68 | handleRemoveClicked(p)}> 69 |
70 | )} 71 |
72 | )} 73 |
74 | 75 |
76 |
77 | 78 | } 79 | 80 | const GroupsCreatorGroupView = (props: {groupMembers: MicrosoftGraph.User[], name: string}) => { 81 | 82 | return ( 83 |
84 |
{props.name}
85 |
86 | {props.groupMembers.map((p, i) => 87 |
88 | 89 | 90 |
91 | )} 92 |
93 |
94 | ); 95 | } 96 | 97 | /** 98 | * Shuffles array in place. 99 | * from: https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array 100 | * @param {Array} a items An array containing the items. 101 | */ 102 | function shuffle(a) { 103 | var j, x, i; 104 | for (i = a.length - 1; i > 0; i--) { 105 | j = Math.floor(Math.random() * (i + 1)); 106 | x = a[i]; 107 | a[i] = a[j]; 108 | a[j] = x; 109 | } 110 | return a; 111 | } 112 | 113 | const generateGroups = (users: MicrosoftGraph.User[], groupSize: number) => { 114 | const shuffled = shuffle(users); 115 | const numberOfGroups = Math.ceil(shuffled.length / groupSize); 116 | const groups = []; 117 | 118 | for (let i = 0; i < numberOfGroups; i++) { 119 | groups.push([]); 120 | } 121 | 122 | let personCount = 0; 123 | let groupNumber = 0; 124 | 125 | for (const person of shuffled) { 126 | if (personCount === groupSize) { 127 | groupNumber++; 128 | personCount = 0; 129 | } 130 | 131 | groups[groupNumber].push(person); 132 | 133 | personCount++; 134 | } 135 | 136 | return groups; 137 | } 138 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if ('serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | 36 | if (publicUrl.origin !== window.location.origin) { 37 | // Our service worker won't work if PUBLIC_URL is on a different origin 38 | // from what our page is served on. This might happen if a CDN is used to 39 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 40 | return; 41 | } 42 | 43 | window.addEventListener('load', () => { 44 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 45 | 46 | if (isLocalhost) { 47 | // This is running on localhost. Let's check if a service worker still exists or not. 48 | checkValidServiceWorker(swUrl, config); 49 | 50 | // Add some additional logging to localhost, pointing developers to the 51 | // service worker/PWA documentation. 52 | navigator.serviceWorker.ready.then(() => { 53 | console.log( 54 | 'This web app is being served cache-first by a service ' + 55 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 56 | ); 57 | }); 58 | } else { 59 | // Is not localhost. Just register service worker 60 | registerValidSW(swUrl, config); 61 | } 62 | }); 63 | } 64 | } 65 | 66 | function registerValidSW(swUrl: string, config?: Config) { 67 | navigator.serviceWorker 68 | .register(swUrl) 69 | .then(registration => { 70 | registration.onupdatefound = () => { 71 | const installingWorker = registration.installing; 72 | if (installingWorker == null) { 73 | return; 74 | } 75 | installingWorker.onstatechange = () => { 76 | if (installingWorker.state === 'installed') { 77 | if (navigator.serviceWorker.controller) { 78 | // At this point, the updated precached content has been fetched, 79 | // but the previous service worker will still serve the older 80 | // content until all client tabs are closed. 81 | console.log( 82 | 'New content is available and will be used when all ' + 83 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 84 | ); 85 | 86 | // Execute callback 87 | if (config && config.onUpdate) { 88 | config.onUpdate(registration); 89 | } 90 | } else { 91 | // At this point, everything has been precached. 92 | // It's the perfect time to display a 93 | // "Content is cached for offline use." message. 94 | console.log('Content is cached for offline use.'); 95 | 96 | // Execute callback 97 | if (config && config.onSuccess) { 98 | config.onSuccess(registration); 99 | } 100 | } 101 | } 102 | }; 103 | }; 104 | }) 105 | .catch(error => { 106 | console.error('Error during service worker registration:', error); 107 | }); 108 | } 109 | 110 | function checkValidServiceWorker(swUrl: string, config?: Config) { 111 | // Check if the service worker can be found. If it can't reload the page. 112 | fetch(swUrl, { 113 | headers: { 'Service-Worker': 'script' } 114 | }) 115 | .then(response => { 116 | // Ensure service worker exists, and that we really are getting a JS file. 117 | const contentType = response.headers.get('content-type'); 118 | if ( 119 | response.status === 404 || 120 | (contentType != null && contentType.indexOf('javascript') === -1) 121 | ) { 122 | // No service worker found. Probably a different app. Reload the page. 123 | navigator.serviceWorker.ready.then(registration => { 124 | registration.unregister().then(() => { 125 | window.location.reload(); 126 | }); 127 | }); 128 | } else { 129 | // Service worker found. Proceed as normal. 130 | registerValidSW(swUrl, config); 131 | } 132 | }) 133 | .catch(() => { 134 | console.log( 135 | 'No internet connection found. App is running in offline mode.' 136 | ); 137 | }); 138 | } 139 | 140 | export function unregister() { 141 | if ('serviceWorker' in navigator) { 142 | navigator.serviceWorker.ready 143 | .then(registration => { 144 | registration.unregister(); 145 | }) 146 | .catch(error => { 147 | console.error(error.message); 148 | }); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/graph.teams.ts: -------------------------------------------------------------------------------- 1 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 2 | import {ResponseType, BatchRequestStep, BatchRequestContent} from '@microsoft/microsoft-graph-client' 3 | import { GroupInfo } from './types'; 4 | import { getClient } from './graph'; 5 | 6 | const timeout = (ms: number) => { 7 | return new Promise(resolve => setTimeout(resolve, ms)); 8 | } 9 | 10 | export const createTeamAndChannelsFromGroups = async (groups: MicrosoftGraph.User[][], teamName: string) => { 11 | const team = { 12 | "template@odata.bind": "https://graph.microsoft.com/beta/teamsTemplates('standard')", 13 | "visibility": "private", 14 | "displayName": teamName, 15 | "description": "This is a team used for breakout discussions", 16 | channels: [], 17 | // installedApps: [ 18 | // { 19 | // // TODO: id here needs to be the one from the Teams store. 20 | // // Upload the manifest/app to the store (for Contoso) 21 | // // then find the app in the store and click on the ... 22 | // // click copy link - the link will contain the id of the app 23 | // 'teamsApp@odata.bind': "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps('com.microsoft.teams.moderator')" 24 | // } 25 | // ] 26 | }; 27 | 28 | for (let i = 0; i < groups.length; i++) { 29 | // const group = groups[i]; 30 | team.channels.push({ 31 | "displayName": `Group ${i+1}` 32 | }) 33 | } 34 | 35 | let response: Response; 36 | try { 37 | response = await getClient().api(`teams`) 38 | .version('beta') 39 | .responseType(ResponseType.RAW).post(team); 40 | } catch (e) { 41 | return null; 42 | } 43 | 44 | if (!response.ok) { 45 | return null; 46 | } 47 | 48 | // need to check the status of our job 49 | // https://docs.microsoft.com/en-us/graph/api/resources/teamsasyncoperationstatus?view=graph-rest-beta 50 | const location = response.headers.get('Location') 51 | let status = 'inProgress'; 52 | let operationResult = null; 53 | 54 | 55 | while (status === 'inProgress' || status === 'notStarted') { 56 | // need to wait to ensure team and channels are created 57 | await timeout(10000); 58 | try { 59 | operationResult = await getClient().api(location).version('beta').get(); 60 | status = operationResult.status; 61 | } catch (e) { } 62 | } 63 | 64 | if (status !== 'succeeded') { 65 | // something went wrong creating the team 66 | return null; 67 | } 68 | 69 | return operationResult.targetResourceId; 70 | } 71 | 72 | export const addUserToTeam = async (teamId: string, userId: string) => { 73 | try { 74 | await getClient().api(`groups/${teamId}/members/$ref`).post({ 75 | "@odata.id": `https://graph.microsoft.com/beta/directoryObjects/${userId}` 76 | }) 77 | } catch (e) { 78 | return null; 79 | } 80 | } 81 | 82 | export const addUsersToTeam = async (teamId: string, userIds: string[]) => { 83 | 84 | let requests :BatchRequestStep[] = []; 85 | 86 | for (const i in userIds) { 87 | const id = userIds[i]; 88 | 89 | const content = { 90 | "@odata.id": `https://graph.microsoft.com/beta/directoryObjects/${id}` 91 | } 92 | 93 | const request = new Request(`/groups/${teamId}/members/$ref`, { 94 | method: 'POST', 95 | body: JSON.stringify(content), 96 | headers: { 97 | 'Content-Type': 'application/json' 98 | } 99 | }); 100 | 101 | requests.push({ 102 | id: i, 103 | request 104 | }) 105 | } 106 | 107 | while (requests.length > 0) { 108 | 109 | // batch supports 20 requests at a time 110 | const currentRequests = requests.splice(0, 20); 111 | const content = await (new BatchRequestContent(currentRequests)).getContent() 112 | 113 | try { 114 | await getClient().api('/$batch').post(content); 115 | } catch (e) {} 116 | } 117 | } 118 | 119 | // export const addUsersToTeam = async (teamId: string, userIds: string[]) => { 120 | 121 | // while (userIds.length > 0) { 122 | // const currentUsers = userIds.splice(0, 20); 123 | 124 | // let content = { 125 | // 'members@odata.bind': currentUsers.map(id => `https://graph.microsoft.com/v1.0/directoryObjects/${id}`) 126 | // } 127 | 128 | // try { 129 | // await getClient().api(`groups/${teamId}`).patch(content); 130 | // } catch (e) {} 131 | // } 132 | // } 133 | 134 | // export const addUsersToTeam = async (teamId: string, userIds: string[]) => { 135 | // for (const id of userIds) { 136 | // await addUserToTeam(teamId, id); 137 | // } 138 | // } 139 | 140 | export const getChannelsForTeam: (teamId: string) => Promise = async (teamId: string) => { 141 | try { 142 | return (await getClient().api(`teams/${teamId}/channels`).get()).value; 143 | } catch (e) { 144 | return null; 145 | } 146 | } 147 | 148 | export const sendMessageToChannel = async (teamId: string, channelId: string, onlineMeetingUrl: string, mentioned: MicrosoftGraph.User[]) => { 149 | try { 150 | const response = await getClient() 151 | .api(`/teams/${teamId}/channels/${channelId}/messages`) 152 | .version('beta') 153 | .post(getMessageContentForChannel(onlineMeetingUrl, mentioned)); 154 | return response; 155 | } catch (e) { 156 | return null; 157 | } 158 | } 159 | 160 | export const sendMessageToChannels = async (teamId: string, groups: GroupInfo[]) => { 161 | let requests: BatchRequestStep[] = []; 162 | 163 | for (const i in groups) { 164 | const group = groups[i] 165 | 166 | const content = getMessageContentForChannel(group.onlineMeeting, group.members); 167 | 168 | const request = new Request(`/teams/${teamId}/channels/${group.id}/messages`, { 169 | method: 'POST', 170 | body: JSON.stringify(content), 171 | headers: { 172 | 'Content-Type': 'application/json' 173 | } 174 | }); 175 | 176 | requests.push({ 177 | id: i.toString(), 178 | request 179 | }) 180 | } 181 | 182 | while (requests.length > 0) { 183 | 184 | // batch supports 20 requests at a time 185 | const currentRequests = requests.splice(0, 20); 186 | const content = await (new BatchRequestContent(currentRequests)).getContent() 187 | 188 | try { 189 | await getClient().api('/$batch').version('beta').post(content); 190 | } catch (e) {} 191 | } 192 | } 193 | 194 | export const archiveTeam = async (teamId) => { 195 | try { 196 | await getClient()?.api(`/teams/${teamId}/archive`).post({}); 197 | } catch (e) { 198 | return null; 199 | } 200 | } 201 | 202 | const getMessageContentForChannel = (onlineMeetingUrl: string, mentioned: MicrosoftGraph.User[]) => { 203 | 204 | let messageContent = `

Hey everyone!

205 | Let's use this meeting to have a private breakout! 206 |
207 |

Join meeting

208 |
209 | `; 210 | 211 | let mentions = []; 212 | for (let i = 0; i < mentioned.length; i++) { 213 | const person = mentioned[i]; 214 | messageContent += ` ${person.displayName} `; 215 | mentions.push({ 216 | "id": i, 217 | "mentionText": person.displayName, 218 | "mentioned": { 219 | "user": { 220 | "displayName": person.displayName, 221 | "id": person.id, 222 | "userIdentityType": "aadUser" 223 | } 224 | } 225 | }) 226 | } 227 | 228 | return { 229 | "body": { 230 | "contentType": "html", 231 | "content": messageContent 232 | }, 233 | "mentions": mentions 234 | } 235 | } -------------------------------------------------------------------------------- /src/App/EventView/BreakoutView/BreakoutView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; 4 | import * as FluentUI from '@fluentui/react'; 5 | import { Person, PersonCardInteraction, PersonViewType } from '@microsoft/mgt-react'; 6 | 7 | import { GraphEvent, GroupInfo } from '../../../utils/types'; 8 | import { getEventExtension, updateEventExtension } from '../../../utils/graph.events'; 9 | import { getCurrentSignedInUser } from '../../../utils/graph'; 10 | import { createOnlineMeetingsForGroups, sendMessageToOnlineMeeting, getChatIdFromOnlineMeeting } from '../../../utils/graph.onlineMeetings'; 11 | import { createTeamAndChannelsFromGroups, addUsersToTeam, getChannelsForTeam, sendMessageToChannels, archiveTeam } from '../../../utils/graph.teams'; 12 | 13 | import { BreakoutsCreatorView } from './BreakoutsCreatorView/BreakoutsCreatorView'; 14 | import './BreakoutView.css'; 15 | import { openTeamsUrl } from '../../../utils/helpers'; 16 | 17 | export interface BreakoutsInfo { 18 | teamName: string, 19 | teamId: string, 20 | moderators: MicrosoftGraph.User[], 21 | groups: GroupInfo[] 22 | } 23 | 24 | export interface BreakoutViewProps { 25 | event: GraphEvent, 26 | attendees: MicrosoftGraph.User[] 27 | } 28 | 29 | export function BreakoutView(props: BreakoutViewProps) { 30 | 31 | const [isLoading, setIsLoading] = useState(true); 32 | const [loadingMessage, setLoadingMessage] = useState(null); 33 | 34 | 35 | const [moderators, setModerators] = useState(null); 36 | const [participants, setParticipants] = useState(null); 37 | const [breakouts, setBreakouts] = useState(null); 38 | 39 | const [currentSignedInUser, setCurrentSignedInUser] = useState(null); 40 | 41 | useEffect(() => { 42 | (async () => { 43 | setIsLoading(true); 44 | 45 | const currentSignedInUser = await getCurrentSignedInUser(); 46 | setCurrentSignedInUser(currentSignedInUser); 47 | 48 | let moderators = [currentSignedInUser]; 49 | 50 | const extension = await getEventExtension(props.event.id); 51 | if (extension && extension.breakouts && extension.breakouts !== '') { 52 | const breakouts : BreakoutsInfo = JSON.parse(extension.breakouts); 53 | moderators = breakouts.moderators; 54 | 55 | setBreakouts(breakouts); 56 | } 57 | 58 | const participants = props.attendees.filter(p => !moderators.find(m => m.id === p.id)) 59 | 60 | setParticipants(participants); 61 | setModerators(moderators); 62 | setIsLoading(false); 63 | })(); 64 | }, []); 65 | 66 | if (isLoading) { 67 | return ( 68 |
69 | 73 |
) 74 | ; 75 | } 76 | 77 | const handleModeratorsAdded = (person) => { 78 | setModerators([...moderators, person]); 79 | setParticipants(participants.filter(p => p.id !== person.id)) 80 | } 81 | 82 | const handleModeratorsRemoved = (person) => { 83 | setModerators(moderators.filter(m => m.id !== person.id)); 84 | setParticipants([person, ...participants]); 85 | } 86 | 87 | const handleCreateGroups = async (groups: MicrosoftGraph.User[][]) => { 88 | setIsLoading(true); 89 | 90 | const teamName = props.event.subject + ' Breakouts'; 91 | 92 | setLoadingMessage(`Creating Team "${teamName}"` ) 93 | const teamId = await createTeamAndChannelsFromGroups(groups, teamName); 94 | if (!teamId) return; 95 | 96 | const userIds = [...participants, ...moderators] 97 | .filter(u => u.id !== currentSignedInUser.id) 98 | .map(u => u.id); 99 | setLoadingMessage(`Adding users to team`) 100 | await addUsersToTeam(teamId, userIds); 101 | 102 | const channels = await getChannelsForTeam(teamId); 103 | if (!channels) return; 104 | 105 | setLoadingMessage(`Creating online meetings`) 106 | const onlineMeetings = await createOnlineMeetingsForGroups( 107 | props.event.start.dateTime, 108 | props.event.end.dateTime, 109 | groups.length); 110 | 111 | 112 | setLoadingMessage(`Sending messages to each channel`) 113 | const groupInfo: GroupInfo[] = []; 114 | 115 | for (const channel of channels) { 116 | 117 | const match = channel.displayName.match(/Group ([0-9]+)/); 118 | if (match && match.length > 1) { 119 | const index = Number.parseInt(match[1]) - 1; 120 | const groupName = `Group ${index + 1}`; 121 | 122 | const group = groups[index]; 123 | const onlineMeeting = onlineMeetings[index]; 124 | 125 | groupInfo.push({ 126 | name: groupName, 127 | id: channel.id, 128 | onlineMeeting: onlineMeeting.joinUrl, 129 | members: group.map(m => {return {id: m.id, displayName: m.displayName}}) 130 | }); 131 | } 132 | } 133 | 134 | await sendMessageToChannels(teamId, groupInfo); 135 | 136 | const breakouts: BreakoutsInfo = { 137 | teamName, 138 | teamId, 139 | moderators: moderators.map(m => {return {id: m.id, displayName: m.displayName}}), 140 | groups: groupInfo 141 | }; 142 | 143 | updateEventExtension(props.event.id, breakouts); 144 | setBreakouts(breakouts); 145 | setIsLoading(false); 146 | } 147 | 148 | const archiveBreakouts = async () => { 149 | setLoadingMessage('Archiving team'); 150 | setIsLoading(true); 151 | await archiveTeam(breakouts.teamId); 152 | await updateEventExtension(props.event.id, null) 153 | setBreakouts(null); 154 | setIsLoading(false); 155 | }; 156 | 157 | 158 | return ( 159 |
160 | {!breakouts ? 161 | 168 | : 169 | 173 | } 174 |
175 | ); 176 | } 177 | 178 | const Breakouts = (props: {breakouts: BreakoutsInfo, event: GraphEvent, onArchive: () => void}) => { 179 | 180 | const [message, setMessage] = useState('Hey [group-name], you have five minutes left! [meeting-link]') 181 | const [isLoading, setIsLoading] = useState(false); 182 | const [showDialog, setShowDialog] = useState(false); 183 | 184 | const sendMessage = async () => { 185 | setIsLoading(true); 186 | 187 | let msg = message.replace('[meeting-link]', `Join main meeting`) 188 | for (const {onlineMeeting, members, name} of props.breakouts.groups) { 189 | let groupMsg = msg.replace('[group-name]', name) 190 | await sendMessageToOnlineMeeting(onlineMeeting, groupMsg, members); 191 | } 192 | setIsLoading(false); 193 | } 194 | 195 | return ( 196 |
197 |
198 |
Send message to all breakouts
199 |
200 | setShowDialog(true)} style={{color:'red', borderColor:'red'}} /> 201 |
202 |
203 |
204 | setMessage(value)} /> 209 |
210 |
211 | The following placeholders will be replaced in the sent message 212 |
- [meeting-link]: link to original meeting
213 |
- [group-name]: name of the group where the message is sent
214 |
215 |
216 | {isLoading 217 | ? 218 | : 219 | } 220 |
221 |
222 | {props.breakouts.groups.map((group, i) => 223 |
224 | 225 |
226 | )} 227 |
228 | 229 | 230 | 231 | 249 |
250 | ); 251 | } 252 | 253 | const BreakoutGroup = (props: {group: GroupInfo}) => { 254 | 255 | const handleJoin = () => { 256 | openTeamsUrl(props.group.onlineMeeting); 257 | } 258 | 259 | const handleChat = () => { 260 | let chatId = getChatIdFromOnlineMeeting(props.group.onlineMeeting); 261 | let url = `https://teams.microsoft.com/l/chat/${chatId}`; 262 | openTeamsUrl(url); 263 | } 264 | 265 | return ( 266 |
267 |
{props.group.name}
268 |
269 | {props.group.members.map((p, i) => 270 |
271 | 272 |
273 | )} 274 |
275 | 276 | 277 |
278 |
279 |
280 | ); 281 | } -------------------------------------------------------------------------------- /src/images/tandem-bicycle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | --------------------------------------------------------------------------------