diff --git a/.env.example b/.env.example index 1ac893a2..550d3533 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,7 @@ AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Uncomment to run in stage environment. # Must also use correct credentials for stage. # REACT_APP_TWILIO_ENVIRONMENT=stage -# TWILIO_REGION=stage \ No newline at end of file +# TWILIO_REGION=stage + +# Un-comment the following line to disable the Twilio Conversations functionality in the app. +# DISABLE_CHAT=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 175a9889..9bd37121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 1.1.0 (July 11, 2022) + +### New Feature + +- This release adds a chat feature for the host, speakers, and viewers. This feature allows all users to send and receive textual messages to each other while connected to a stream. This feature is powered by the [Twilio Conversations API](https://www.twilio.com/conversations-api) and is optional. See the [README.md](https://github.com/twilio/twilio-live-interactive-video/blob/feature/audience-chat/README.md#set-your-account-sid-and-auth-token) for more information on how to opt out. + +### Bug Fixes + +- Fixes an issue where the host could not create more than one stream. [#116](https://github.com/twilio/twilio-live-interactive-video/pull/116) + # 1.0.0 (February 28, 2022) This is the initial release of the Twilio Live Interactive Video iOS and web Apps. This project demonstrates an interactive live video streaming app that uses [Twilio Live](https://www.twilio.com/docs/live), [Twilio Video](https://www.twilio.com/docs/video) and [Twilio Sync](https://www.twilio.com/docs/sync). diff --git a/README.md b/README.md index 64133aae..cebdf04f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ Copy the `.env.example` file to `.env` and perform the following one-time steps Update the ACCOUNT_SID and AUTH_TOKEN `.env` entries with the Account SID and Auth Token found on the [Twilio Console home page](https://twilio.com/console). +**NOTE**: the use of Twilio Conversations is optional. If you wish to opt out, set the `DISABLE_CHAT` environment variable to `true`. + #### Install Dependencies Once you have setup all your environment variables, run `npm install` to install all dependencies from NPM. diff --git a/apps/web/src/components/Buttons/EndEventButton/EndEventButton.tsx b/apps/web/src/components/Buttons/EndEventButton/EndEventButton.tsx index 601e4637..4a752444 100644 --- a/apps/web/src/components/Buttons/EndEventButton/EndEventButton.tsx +++ b/apps/web/src/components/Buttons/EndEventButton/EndEventButton.tsx @@ -2,7 +2,8 @@ import React from 'react'; import clsx from 'clsx'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { Button } from '@material-ui/core'; +import { Button, Hidden } from '@material-ui/core'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; import { useAppState } from '../../../state'; import { deleteStream } from '../../../state/api/api'; @@ -40,7 +41,10 @@ export default function EndCallButton(props: { className?: string }) { return ( ); } diff --git a/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx b/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx index 6b39b1dd..2059fc99 100644 --- a/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx +++ b/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx @@ -1,8 +1,10 @@ import React, { useState, useRef } from 'react'; -import { Button, Menu as MenuContainer, MenuItem, Typography } from '@material-ui/core'; +import clsx from 'clsx'; +import { Button, Menu as MenuContainer, MenuItem, Typography, Hidden } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import { joinStreamAsViewer, connectViewerToPlayer } from '../../../state/api/api'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; import { useAppState } from '../../../state'; import useChatContext from '../../../hooks/useChatContext/useChatContext'; import usePlayerContext from '../../../hooks/usePlayerContext/usePlayerContext'; @@ -21,7 +23,7 @@ const useStyles = makeStyles((theme: Theme) => }) ); -export default function LeaveEventButton(props: { buttonClassName?: string }) { +export default function LeaveEventButton() { const classes = useStyles(); const [menuOpen, setMenuOpen] = useState(false); const { room } = useVideoContext(); @@ -53,9 +55,17 @@ export default function LeaveEventButton(props: { buttonClassName?: string }) { return ( <> - : } data-cy-audio-toggle > - {!hasAudioTrack ? 'No Audio' : isAudioEnabled ? 'Mute' : 'Unmute'} + {!hasAudioTrack ? 'No Audio' : isAudioEnabled ? 'Mute' : 'Unmute'} ); } diff --git a/apps/web/src/components/Buttons/ToggleChatButton/ToggleChatButton.tsx b/apps/web/src/components/Buttons/ToggleChatButton/ToggleChatButton.tsx index 8d067c59..0b89208b 100644 --- a/apps/web/src/components/Buttons/ToggleChatButton/ToggleChatButton.tsx +++ b/apps/web/src/components/Buttons/ToggleChatButton/ToggleChatButton.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import Button from '@material-ui/core/Button'; import ChatIcon from '../../../icons/ChatIcon'; import clsx from 'clsx'; -import { makeStyles } from '@material-ui/core'; +import { makeStyles, Hidden } from '@material-ui/core'; import useChatContext from '../../../hooks/useChatContext/useChatContext'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; import { useAppState } from '../../../state'; @@ -78,7 +78,6 @@ export default function ToggleChatButton() { setTimeout(() => setShouldAnimate(false), ANIMATION_DURATION); } }, [shouldAnimate]); - useEffect(() => { if (conversation && !isChatWindowOpen) { const handleNewMessage = () => setShouldAnimate(true); @@ -94,6 +93,7 @@ export default function ToggleChatButton() { data-cy-chat-button onClick={toggleChatWindow} disabled={!conversation} + className="MuiButton-mobileBackground" startIcon={
@@ -102,7 +102,7 @@ export default function ToggleChatButton() {
} > - Chat + Chat ); } diff --git a/apps/web/src/components/Buttons/ToggleParticipantWindow/ToggleParticipantWindowButton.tsx b/apps/web/src/components/Buttons/ToggleParticipantWindow/ToggleParticipantWindowButton.tsx index aef25f7d..1f00fd4f 100644 --- a/apps/web/src/components/Buttons/ToggleParticipantWindow/ToggleParticipantWindowButton.tsx +++ b/apps/web/src/components/Buttons/ToggleParticipantWindow/ToggleParticipantWindowButton.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import Button from '@material-ui/core/Button'; import clsx from 'clsx'; -import { makeStyles } from '@material-ui/core'; +import { makeStyles, Hidden } from '@material-ui/core'; import ParticipantIcon from '../../../icons/ParticipantIcon'; import { useAppState } from '../../../state'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; @@ -96,6 +96,7 @@ export default function ToggleParticipantWindowButton() { return ( ); } diff --git a/apps/web/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx b/apps/web/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx index a630267f..3e1a6017 100644 --- a/apps/web/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx +++ b/apps/web/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx @@ -26,7 +26,9 @@ export default function ToggleVideoButton(props: { disabled?: boolean; className disabled={!hasVideoInputDevices || props.disabled} startIcon={isVideoEnabled ? : } > - {!hasVideoInputDevices ? 'No Video' : isVideoEnabled ? 'Stop Video' : 'Start Video'} + + {!hasVideoInputDevices ? 'No Video' : isVideoEnabled ? 'Stop Video' : 'Start Video'} + ); } diff --git a/apps/web/src/components/ChatProvider/index.tsx b/apps/web/src/components/ChatProvider/index.tsx index 2d6c3a7d..4ac513f3 100644 --- a/apps/web/src/components/ChatProvider/index.tsx +++ b/apps/web/src/components/ChatProvider/index.tsx @@ -7,7 +7,7 @@ import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; type ChatContextType = { isChatWindowOpen: boolean; setIsChatWindowOpen: (isChatWindowOpen: boolean) => void; - connect: (token: string) => void; + connect: (token: string, roomSid: string) => void; disconnect: () => void; hasUnreadMessages: boolean; messages: Message[]; @@ -17,18 +17,21 @@ type ChatContextType = { export const ChatContext = createContext(null!); export const ChatProvider: React.FC = ({ children }) => { - const { room, onError } = useVideoContext(); + const { onError } = useVideoContext(); const isChatWindowOpenRef = useRef(false); const [isChatWindowOpen, setIsChatWindowOpen] = useState(false); const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState([]); const [hasUnreadMessages, setHasUnreadMessages] = useState(false); + const [videoRoomSid, setVideoRoomSid] = useState(''); const [chatClient, setChatClient] = useState(); const connect = useCallback( - (token: string) => { + (token: string, roomSid: string) => { if (!chatClient) { + setVideoRoomSid(roomSid); let conversationOptions; + if (process.env.REACT_APP_TWILIO_ENVIRONMENT) { conversationOptions = { region: `${process.env.REACT_APP_TWILIO_ENVIRONMENT}-us1` }; } @@ -76,9 +79,9 @@ export const ChatProvider: React.FC = ({ children }) => { }, [isChatWindowOpen]); useEffect(() => { - if (room && chatClient) { + if (videoRoomSid && chatClient) { chatClient - .getConversationByUniqueName(room.sid) + .getConversationByUniqueName(videoRoomSid) .then(newConversation => { //@ts-ignore window.chatConversation = newConversation; @@ -89,7 +92,7 @@ export const ChatProvider: React.FC = ({ children }) => { onError(new Error('There was a problem getting the Conversation associated with this room.')); }); } - }, [room, chatClient, onError]); + }, [chatClient, onError, videoRoomSid]); return ( ({ margin: '1em 0 0 1em', display: 'flex', }, - fileButtonContainer: { - position: 'relative', - marginRight: '1em', - }, - fileButtonLoadingSpinner: { - position: 'absolute', - top: '50%', - left: '50%', - marginTop: -12, - marginLeft: -12, - }, textAreaContainer: { display: 'flex', marginTop: '0.4em', @@ -64,17 +52,12 @@ interface ChatInputProps { isChatWindowOpen: boolean; } -const ALLOWED_FILE_TYPES = - 'audio/*, image/*, text/*, video/*, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document .xslx, .ppt, .pdf, .key, .svg, .csv'; - export default function ChatInput({ conversation, isChatWindowOpen }: ChatInputProps) { const classes = useStyles(); const [messageBody, setMessageBody] = useState(''); - const [isSendingFile, setIsSendingFile] = useState(false); const [fileSendError, setFileSendError] = useState(null); const isValidMessage = /\S/.test(messageBody); const textInputRef = useRef(null); - const fileInputRef = useRef(null); const [isTextareaFocused, setIsTextareaFocused] = useState(false); useEffect(() => { @@ -104,29 +87,6 @@ export default function ChatInput({ conversation, isChatWindowOpen }: ChatInputP } }; - const handleSendFile = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - var formData = new FormData(); - formData.append('userfile', file); - setIsSendingFile(true); - setFileSendError(null); - conversation - .sendMessage(formData) - .catch(e => { - if (e.code === 413) { - setFileSendError('File size is too large. Maximum file size is 150MB.'); - } else { - setFileSendError('There was a problem uploading the file. Please try again.'); - } - console.log('Problem sending file: ', e); - }) - .finally(() => { - setIsSendingFile(false); - }); - } - }; - return (
- {/* Since the file input element is invisible, we can hardcode an empty string as its value. - This allows users to upload the same file multiple times. */} -
-
- - - {isSendingFile && } -
- - + {appState.isChatEnabled && ( + + )} + + + + - - - + {/* Move 'Leave Stream' button all the way to the right if on Desktop */} + + + + + - + ); diff --git a/apps/web/src/components/PreJoinScreens/PreJoinScreens.tsx b/apps/web/src/components/PreJoinScreens/PreJoinScreens.tsx index 10f3b3db..218855e9 100644 --- a/apps/web/src/components/PreJoinScreens/PreJoinScreens.tsx +++ b/apps/web/src/components/PreJoinScreens/PreJoinScreens.tsx @@ -16,10 +16,11 @@ import { useEnqueueSnackbar } from '../../hooks/useSnackbar/useSnackbar'; import usePlayerContext from '../../hooks/usePlayerContext/usePlayerContext'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; import useSyncContext from '../../hooks/useSyncContext/useSyncContext'; +import { isMobile } from '../../utils'; export default function PreJoinScreens() { const { getAudioAndVideoTracks } = useVideoContext(); - const { connect: chatConnect } = useChatContext(); + const { connect: chatConnect, setIsChatWindowOpen } = useChatContext(); const { connect: videoConnect } = useVideoContext(); const { connect: playerConnect, disconnect: playerDisconnect } = usePlayerContext(); const { connect: syncConnect, registerUserDocument, registerSyncMaps } = useSyncContext(); @@ -29,38 +30,46 @@ export default function PreJoinScreens() { async function connect() { appDispatch({ type: 'set-is-loading', isLoading: true }); - try { if (appState.hasSpeakerInvite) { const { data } = await joinStreamAsSpeaker(appState.participantName, appState.eventName); await videoConnect(data.token); - chatConnect(data.token); + if (data.chat_enabled) { + chatConnect(data.token, data.room_sid); + appDispatch({ type: 'set-is-chat-enabled', isChatEnabled: true }); + if (!isMobile) setIsChatWindowOpen(true); + } registerSyncMaps(data.sync_object_names); playerDisconnect(); appDispatch({ type: 'set-is-loading', isLoading: false }); appDispatch({ type: 'set-has-speaker-invite', hasSpeakerInvite: false }); return; } - switch (appState.participantType) { case 'host': { const { data } = await createStream(appState.participantName, appState.eventName); syncConnect(data.token); await videoConnect(data.token); registerSyncMaps(data.sync_object_names); - chatConnect(data.token); + if (data.chat_enabled) { + chatConnect(data.token, data.room_sid); + appDispatch({ type: 'set-is-chat-enabled', isChatEnabled: true }); + if (!isMobile) setIsChatWindowOpen(true); + } break; } - case 'speaker': { const { data } = await joinStreamAsSpeaker(appState.participantName, appState.eventName); syncConnect(data.token); await videoConnect(data.token); registerSyncMaps(data.sync_object_names); - chatConnect(data.token); + if (data.chat_enabled) { + chatConnect(data.token, data.room_sid); + appDispatch({ type: 'set-is-chat-enabled', isChatEnabled: true }); + if (!isMobile) setIsChatWindowOpen(true); + } break; } - case 'viewer': { const { data } = await joinStreamAsViewer(appState.participantName, appState.eventName); syncConnect(data.token); @@ -68,7 +77,11 @@ export default function PreJoinScreens() { registerUserDocument(data.sync_object_names.user_document); registerSyncMaps(data.sync_object_names); await connectViewerToPlayer(appState.participantName, appState.eventName); - // chatConnect(data.token); + if (data.chat_enabled) { + chatConnect(data.token, data.room_sid); + appDispatch({ type: 'set-is-chat-enabled', isChatEnabled: true }); + if (!isMobile) setIsChatWindowOpen(true); + } break; } } @@ -76,7 +89,6 @@ export default function PreJoinScreens() { } catch (e) { console.log('Error connecting: ', e.toJSON ? e.toJSON() : e); appDispatch({ type: 'set-is-loading', isLoading: false }); - if (e.response?.data?.error?.explanation === 'Room exists') { enqueueSnackbar({ headline: 'Error', @@ -112,7 +124,6 @@ export default function PreJoinScreens() { return ( - {appState.isLoading ? ( ) : ( diff --git a/apps/web/src/state/api/api.ts b/apps/web/src/state/api/api.ts index 50e4deb4..a7e55533 100644 --- a/apps/web/src/state/api/api.ts +++ b/apps/web/src/state/api/api.ts @@ -10,6 +10,8 @@ export const apiClient = axios.create({ export const createStream = (user_identity: string, stream_name: string) => apiClient.post<{ token: string; + room_sid: string; + chat_enabled: boolean; sync_object_names: { speakers_map: string; viewers_map: string; @@ -23,6 +25,8 @@ export const createStream = (user_identity: string, stream_name: string) => export const joinStreamAsSpeaker = (user_identity: string, stream_name: string) => apiClient.post<{ token: string; + room_sid: string; + chat_enabled: boolean; sync_object_names: { speakers_map: string; viewers_map: string; @@ -38,6 +42,7 @@ export const joinStreamAsViewer = (user_identity: string, stream_name: string) = apiClient.post<{ token: string; room_sid: string; + chat_enabled: boolean; sync_object_names: { speakers_map: string; viewers_map: string; diff --git a/apps/web/src/state/appState/appReducer.ts b/apps/web/src/state/appState/appReducer.ts index 06e2bba7..2acd0c45 100644 --- a/apps/web/src/state/appState/appReducer.ts +++ b/apps/web/src/state/appState/appReducer.ts @@ -17,7 +17,8 @@ export type appActionTypes = | { type: 'set-is-loading'; isLoading: boolean } | { type: 'set-has-speaker-invite'; hasSpeakerInvite: boolean } | { type: 'reset-state' } - | { type: 'set-is-participant-window-open'; isParticipantWindowOpen: boolean }; + | { type: 'set-is-participant-window-open'; isParticipantWindowOpen: boolean } + | { type: 'set-is-chat-enabled'; isChatEnabled: boolean }; export interface appStateTypes { activeScreen: ActiveScreen; @@ -28,6 +29,7 @@ export interface appStateTypes { isLoading: boolean; hasSpeakerInvite: boolean; isParticipantWindowOpen: boolean; + isChatEnabled: boolean; } export const initialAppState: appStateTypes = { @@ -39,6 +41,7 @@ export const initialAppState: appStateTypes = { isLoading: false, hasSpeakerInvite: false, isParticipantWindowOpen: false, + isChatEnabled: false, }; export const appReducer = produce((draft: appStateTypes, action: appActionTypes) => { @@ -94,7 +97,10 @@ export const appReducer = produce((draft: appStateTypes, action: appActionTypes) draft.activeScreen = ActiveScreen.SpeakerOrViewerScreen; break; } + break; + case 'set-is-chat-enabled': + draft.isChatEnabled = action.isChatEnabled; break; } }); diff --git a/package-lock.json b/package-lock.json index b6c280d5..510da0df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twilio-live-interactive-video", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "twilio-live-interactive-video", - "version": "1.0.0", + "version": "1.1.0", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 49067da5..75911312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twilio-live-interactive-video", - "version": "1.0.0", + "version": "1.1.0", "description": "", "main": "index.js", "scripts": { diff --git a/serverless/functions/create-stream.js b/serverless/functions/create-stream.js index 9c299f9d..b3b02134 100644 --- a/serverless/functions/create-stream.js +++ b/serverless/functions/create-stream.js @@ -17,6 +17,7 @@ module.exports.handler = async (context, event, callback) => { BACKEND_STORAGE_SYNC_SERVICE_SID, SYNC_SERVICE_NAME_PREFIX, DOMAIN_NAME, + DISABLE_CHAT, } = context; const authHandler = require(Runtime.getAssets()['/auth.js'].path); @@ -293,44 +294,46 @@ module.exports.handler = async (context, event, callback) => { return callback(null, response); } - const conversationsClient = client.conversations.services(CONVERSATIONS_SERVICE_SID); + if (DISABLE_CHAT !== 'true') { + const conversationsClient = client.conversations.services(CONVERSATIONS_SERVICE_SID); - try { - // Here we add a timer to close the conversation after the maximum length of a room (24 hours). - // This helps to clean up old conversations since there is a limit that a single participant - // can not be added to more than 1,000 open conversations. - conversation = await conversationsClient.conversations.create({ - uniqueName: room.sid, - 'timers.closed': 'P1D', - }); - } catch (e) { - console.error(e); - response.setStatusCode(500); - response.setBody({ - error: { - message: 'error creating conversation', - explanation: 'Something went wrong when creating a conversation.', - }, - }); - return callback(null, response); - } - - try { - // Add participant to conversation - await conversationsClient.conversations(room.sid).participants.create({ identity: user_identity }); - } catch (e) { - // Ignore "Participant already exists" error (50433) - if (e.code !== 50433) { + try { + // Here we add a timer to close the conversation after the maximum length of a room (24 hours). + // This helps to clean up old conversations since there is a limit that a single participant + // can not be added to more than 1,000 open conversations. + conversation = await conversationsClient.conversations.create({ + uniqueName: room.sid, + 'timers.closed': 'P1D', + }); + } catch (e) { console.error(e); response.setStatusCode(500); response.setBody({ error: { - message: 'error creating conversation participant', - explanation: 'Something went wrong when creating a conversation participant.', + message: 'error creating conversation', + explanation: 'Something went wrong when creating a conversation.', }, }); return callback(null, response); } + + try { + // Add participant to conversation + await conversationsClient.conversations(room.sid).participants.create({ identity: user_identity }); + } catch (e) { + // Ignore "Participant already exists" error (50433) + if (e.code !== 50433) { + console.error(e); + response.setStatusCode(500); + response.setBody({ + error: { + message: 'error creating conversation participant', + explanation: 'Something went wrong when creating a conversation participant.', + }, + }); + return callback(null, response); + } + } } // Create token @@ -346,8 +349,10 @@ module.exports.handler = async (context, event, callback) => { token.addGrant(videoGrant); // Add chat grant to token - const chatGrant = new ChatGrant({ serviceSid: CONVERSATIONS_SERVICE_SID }); - token.addGrant(chatGrant); + if (DISABLE_CHAT !== 'true') { + const chatGrant = new ChatGrant({ serviceSid: CONVERSATIONS_SERVICE_SID }); + token.addGrant(chatGrant); + } // Add sync grant to token const syncGrant = new SyncGrant({ serviceSid: streamSyncService.sid }); @@ -362,6 +367,8 @@ module.exports.handler = async (context, event, callback) => { viewers_map: 'viewers', raised_hands_map: `raised_hands`, }, + room_sid: room.sid, + chat_enabled: DISABLE_CHAT !== 'true', }); return callback(null, response); }; diff --git a/serverless/functions/join-stream-as-speaker.js b/serverless/functions/join-stream-as-speaker.js index f477da73..8a23e6ed 100644 --- a/serverless/functions/join-stream-as-speaker.js +++ b/serverless/functions/join-stream-as-speaker.js @@ -8,9 +8,7 @@ const SyncGrant = AccessToken.SyncGrant; const MAX_ALLOWED_SESSION_DURATION = 14400; module.exports.handler = async (context, event, callback) => { - const { ACCOUNT_SID, TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET, CONVERSATIONS_SERVICE_SID } = - context; - + const { ACCOUNT_SID, TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET, CONVERSATIONS_SERVICE_SID, DISABLE_CHAT } = context; const authHandler = require(Runtime.getAssets()['/auth.js'].path); authHandler(context, event, callback); @@ -101,9 +99,10 @@ module.exports.handler = async (context, event, callback) => { // Give user read access to user document try { - await streamSyncClient.documents(userDocumentName) + await streamSyncClient + .documents(userDocumentName) .documentPermissions(user_identity) - .update({ read: true, write: false, manage: false }) + .update({ read: true, write: false, manage: false }); } catch (e) { response.setStatusCode(500); response.setBody({ @@ -114,12 +113,13 @@ module.exports.handler = async (context, event, callback) => { }); return callback(null, response); } - + // Give user read access to speakers map try { - await streamSyncClient.syncMaps('speakers') + await streamSyncClient + .syncMaps('speakers') .syncMapPermissions(user_identity) - .update({ read: true, write: false, manage: false }) + .update({ read: true, write: false, manage: false }); } catch (e) { response.setStatusCode(500); response.setBody({ @@ -130,11 +130,12 @@ module.exports.handler = async (context, event, callback) => { }); return callback(null, response); } - + const raisedHandsMapName = `raised_hands`; // Give user read access to raised hands map try { - await streamSyncClient.syncMaps(raisedHandsMapName) + await streamSyncClient + .syncMaps(raisedHandsMapName) .syncMapPermissions(user_identity) .update({ read: true, write: false, manage: false }); } catch (e) { @@ -150,9 +151,10 @@ module.exports.handler = async (context, event, callback) => { // Give user read access to viewers map try { - await streamSyncClient.syncMaps('viewers') + await streamSyncClient + .syncMaps('viewers') .syncMapPermissions(user_identity) - .update({ read: true, write: false, manage: false }) + .update({ read: true, write: false, manage: false }); } catch (e) { response.setStatusCode(500); response.setBody({ @@ -164,39 +166,41 @@ module.exports.handler = async (context, event, callback) => { return callback(null, response); } - const conversationsClient = client.conversations.services(CONVERSATIONS_SERVICE_SID); + if (DISABLE_CHAT !== 'true') { + const conversationsClient = client.conversations.services(CONVERSATIONS_SERVICE_SID); - try { - // Find conversation - conversation = await conversationsClient.conversations(room.sid).fetch(); - } catch (e) { - console.error(e); - response.setStatusCode(500); - response.setBody({ - error: { - message: 'error finding conversation', - explanation: 'Something went wrong when finding a conversation.', - }, - }); - return callback(null, response); - } - - try { - // Add participant to conversation - await conversationsClient.conversations(room.sid).participants.create({ identity: user_identity }); - } catch (e) { - // Ignore "Participant already exists" error (50433) - if (e.code !== 50433) { + try { + // Find conversation + conversation = await conversationsClient.conversations(room.sid).fetch(); + } catch (e) { console.error(e); response.setStatusCode(500); response.setBody({ error: { - message: 'error creating conversation participant', - explanation: 'Something went wrong when creating a conversation participant.', + message: 'error finding conversation', + explanation: 'Something went wrong when finding a conversation.', }, }); return callback(null, response); } + + try { + // Add participant to conversation + await conversationsClient.conversations(room.sid).participants.create({ identity: user_identity }); + } catch (e) { + // Ignore "Participant already exists" error (50433) + if (e.code !== 50433) { + console.error(e); + response.setStatusCode(500); + response.setBody({ + error: { + message: 'error creating conversation participant', + explanation: 'Something went wrong when creating a conversation participant.', + }, + }); + return callback(null, response); + } + } } // Create token @@ -212,8 +216,10 @@ module.exports.handler = async (context, event, callback) => { token.addGrant(videoGrant); // Add chat grant to token - const chatGrant = new ChatGrant({ serviceSid: CONVERSATIONS_SERVICE_SID }); - token.addGrant(chatGrant); + if (DISABLE_CHAT !== 'true') { + const chatGrant = new ChatGrant({ serviceSid: CONVERSATIONS_SERVICE_SID }); + token.addGrant(chatGrant); + } // Add sync grant to token const syncGrant = new SyncGrant({ serviceSid: streamMapItem.data.sync_service_sid }); @@ -229,6 +235,8 @@ module.exports.handler = async (context, event, callback) => { raised_hands_map: `raised_hands`, user_document: `user-${user_identity}`, }, + room_sid: room.sid, + chat_enabled: DISABLE_CHAT !== 'true', }); return callback(null, response); }; diff --git a/serverless/functions/join-stream-as-viewer.js b/serverless/functions/join-stream-as-viewer.js index a5f491f6..193e7fcf 100644 --- a/serverless/functions/join-stream-as-viewer.js +++ b/serverless/functions/join-stream-as-viewer.js @@ -7,6 +7,7 @@ const ChatGrant = AccessToken.ChatGrant; const MAX_ALLOWED_SESSION_DURATION = 14400; module.exports.handler = async (context, event, callback) => { + const { DISABLE_CHAT } = context; const authHandler = require(Runtime.getAssets()['/auth.js'].path); authHandler(context, event, callback); @@ -40,7 +41,7 @@ module.exports.handler = async (context, event, callback) => { return callback(null, response); } - let room, streamMapItem, userDocument; + let room, streamMapItem, userDocument, conversation; const client = context.getTwilioClient(); @@ -117,7 +118,8 @@ module.exports.handler = async (context, event, callback) => { // Give user read access to user document try { - await streamSyncClient.documents(userDocumentName) + await streamSyncClient + .documents(userDocumentName) .documentPermissions(user_identity) .update({ read: true, write: false, manage: false }); } catch (e) { @@ -133,9 +135,10 @@ module.exports.handler = async (context, event, callback) => { // Give user read access to speakers map try { - await streamSyncClient.syncMaps('speakers') + await streamSyncClient + .syncMaps('speakers') .syncMapPermissions(user_identity) - .update({ read: true, write: false, manage: false }) + .update({ read: true, write: false, manage: false }); } catch (e) { response.setStatusCode(500); response.setBody({ @@ -146,10 +149,11 @@ module.exports.handler = async (context, event, callback) => { }); return callback(null, response); } - + // Give user read access to raised hands map try { - await streamSyncClient.syncMaps(`raised_hands`) + await streamSyncClient + .syncMaps(`raised_hands`) .syncMapPermissions(user_identity) .update({ read: true, write: false, manage: false }); } catch (e) { @@ -165,9 +169,10 @@ module.exports.handler = async (context, event, callback) => { // Give user read access to viewers map try { - await streamSyncClient.syncMaps('viewers') + await streamSyncClient + .syncMaps('viewers') .syncMapPermissions(user_identity) - .update({ read: true, write: false, manage: false }) + .update({ read: true, write: false, manage: false }); } catch (e) { response.setStatusCode(500); response.setBody({ @@ -178,7 +183,44 @@ module.exports.handler = async (context, event, callback) => { }); return callback(null, response); } - + + if (DISABLE_CHAT !== 'true') { + const conversationsClient = client.conversations.services(context.CONVERSATIONS_SERVICE_SID); + + try { + // Find conversation + conversation = await conversationsClient.conversations(room.sid).fetch(); + } catch (e) { + console.error(e); + response.setStatusCode(500); + response.setBody({ + error: { + message: 'error finding conversation', + explanation: 'Something went wrong when finding a conversation.', + }, + }); + return callback(null, response); + } + + try { + // Add participant to conversation + await conversationsClient.conversations(room.sid).participants.create({ identity: user_identity }); + } catch (e) { + // Ignore "Participant already exists" error (50433) + if (e.code !== 50433) { + console.error(e); + response.setStatusCode(500); + response.setBody({ + error: { + message: 'error creating conversation participant', + explanation: 'Something went wrong when creating a conversation participant.', + }, + }); + return callback(null, response); + } + } + } + let playbackGrant; try { playbackGrant = await getPlaybackGrant(streamMapItem.data.player_streamer_sid); @@ -200,10 +242,10 @@ module.exports.handler = async (context, event, callback) => { }); // Add chat grant to token - const chatGrant = new ChatGrant({ - serviceSid: context.CONVERSATIONS_SERVICE_SID, - }); - token.addGrant(chatGrant); + if (DISABLE_CHAT !== 'true') { + const chatGrant = new ChatGrant({ serviceSid: context.CONVERSATIONS_SERVICE_SID }); + token.addGrant(chatGrant); + } // Add participant's identity to token token.identity = event.user_identity; @@ -230,6 +272,7 @@ module.exports.handler = async (context, event, callback) => { user_document: `user-${user_identity}`, }, room_sid: room.sid, + chat_enabled: DISABLE_CHAT !== 'true', }); callback(null, response); diff --git a/serverless/scripts/deploy.js b/serverless/scripts/deploy.js index 1019e0be..49670d7b 100644 --- a/serverless/scripts/deploy.js +++ b/serverless/scripts/deploy.js @@ -17,7 +17,7 @@ program.option('-o, --override', 'Override existing deployment'); program.parse(process.argv); const options = program.opts(); -const { ACCOUNT_SID, AUTH_TOKEN } = process.env; +const { ACCOUNT_SID, AUTH_TOKEN, DISABLE_CHAT } = process.env; const client = require('twilio')(ACCOUNT_SID, AUTH_TOKEN); const serverlessClient = new TwilioServerlessApiClient({ username: ACCOUNT_SID, @@ -38,6 +38,7 @@ async function findExistingConfiguration() { 'TWILIO_API_KEY_SECRET', 'CONVERSATIONS_SERVICE_SID', 'BACKEND_STORAGE_SYNC_SERVICE_SID', + 'DISABLE_CHAT', ], getValues: true, }); @@ -72,10 +73,12 @@ async function deployFunctions() { friendlyName: constants.API_KEY_NAME, }); - cli.action.start('Creating Conversations Service'); - conversationsService = await client.conversations.services.create({ - friendlyName: constants.TWILIO_CONVERSATIONS_SERVICE_NAME, - }); + if (DISABLE_CHAT !== 'true') { + cli.action.start('Creating Conversations Service'); + conversationsService = await client.conversations.services.create({ + friendlyName: constants.TWILIO_CONVERSATIONS_SERVICE_NAME, + }); + } cli.action.start('Creating Backend Storage Sync Service'); backendStorageSyncService = await client.sync.services.create({ @@ -118,13 +121,14 @@ async function deployFunctions() { env: { TWILIO_API_KEY_SID: existingConfiguration?.TWILIO_API_KEY_SID || apiKey.sid, TWILIO_API_KEY_SECRET: existingConfiguration?.TWILIO_API_KEY_SECRET || apiKey.secret, - CONVERSATIONS_SERVICE_SID: existingConfiguration?.CONVERSATIONS_SERVICE_SID || conversationsService.sid, + CONVERSATIONS_SERVICE_SID: existingConfiguration?.CONVERSATIONS_SERVICE_SID || conversationsService?.sid, BACKEND_STORAGE_SYNC_SERVICE_SID: existingConfiguration?.BACKEND_STORAGE_SYNC_SERVICE_SID || backendStorageSyncService.sid, SYNC_SERVICE_NAME_PREFIX: constants.SYNC_SERVICE_NAME_PREFIX, MEDIA_EXTENSION: constants.MEDIA_EXTENSION, APP_EXPIRY: Date.now() + 1000 * 60 * 60 * 24 * 7, // One week PASSCODE: getRandomInt(6), + DISABLE_CHAT: DISABLE_CHAT, }, pkgJson: { dependencies: {