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 (
- End Event
+ End Event
+
+
+
);
}
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 (
<>
- setMenuOpen(isOpen => !isOpen)} ref={anchorRef} className={classes.button}>
- Leave Event
-
+ setMenuOpen(isOpen => !isOpen)}
+ ref={anchorRef}
+ className={clsx(classes.button, 'MuiButton-mobileBackground')}
+ >
+
+ Leave Event
+
+
+
+
: }
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 (
@@ -109,7 +110,7 @@ export default function ToggleParticipantWindowButton() {
}
>
- Participants
+ Participants
);
}
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. */}
-
-
- fileInputRef.current?.click()} disabled={isSendingFile}>
-
-
-
- {isSendingFile && }
-
-
handleSendMessage(messageBody)}
diff --git a/apps/web/src/components/ChatWindow/ChatWindow.tsx b/apps/web/src/components/ChatWindow/ChatWindow.tsx
index 6eb0b419..70817f6f 100644
--- a/apps/web/src/components/ChatWindow/ChatWindow.tsx
+++ b/apps/web/src/components/ChatWindow/ChatWindow.tsx
@@ -28,11 +28,9 @@ const useStyles = makeStyles((theme: Theme) =>
},
})
);
-
// In this component, we are toggling the visibility of the ChatWindow with CSS instead of
// conditionally rendering the component in the DOM. This is done so that the ChatWindow is
// not unmounted while a file upload is in progress.
-
export default function ChatWindow() {
const classes = useStyles();
const { isChatWindowOpen, messages, conversation } = useChatContext();
diff --git a/apps/web/src/components/ChatWindow/MessageList/MessageList.tsx b/apps/web/src/components/ChatWindow/MessageList/MessageList.tsx
index a4b176b3..93e85f3e 100644
--- a/apps/web/src/components/ChatWindow/MessageList/MessageList.tsx
+++ b/apps/web/src/components/ChatWindow/MessageList/MessageList.tsx
@@ -5,7 +5,7 @@ import MessageListScrollContainer from './MessageListScrollContainer/MessageList
import TextMessage from './TextMessage/TextMessage';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';
import MediaMessage from './MediaMessage/MediaMessage';
-
+import { useAppState } from '../../../state';
interface MessageListProps {
messages: Message[];
}
@@ -15,19 +15,17 @@ const getFormattedTime = (message?: Message) =>
export default function MessageList({ messages }: MessageListProps) {
const { room } = useVideoContext();
- const localParticipant = room!.localParticipant;
+ const { appState } = useAppState();
+ const localParticipant = room ? room.localParticipant.identity : appState.participantName;
return (
{messages.map((message, idx) => {
const time = getFormattedTime(message)!;
const previousTime = getFormattedTime(messages[idx - 1]);
-
// Display the MessageInfo component when the author or formatted timestamp differs from the previous message
const shouldDisplayMessageInfo = time !== previousTime || message.author !== messages[idx - 1]?.author;
-
- const isLocalParticipant = localParticipant.identity === message.author;
-
+ const isLocalParticipant = localParticipant === message.author;
return (
{shouldDisplayMessageInfo && (
diff --git a/apps/web/src/components/MenuBar/MenuBar.tsx b/apps/web/src/components/MenuBar/MenuBar.tsx
index 2a64f45c..5c82f397 100644
--- a/apps/web/src/components/MenuBar/MenuBar.tsx
+++ b/apps/web/src/components/MenuBar/MenuBar.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
-import Button from '@material-ui/core/Button';
+import { Button } from '@material-ui/core';
import EndEventButton from '../Buttons/EndEventButton/EndEventButton';
import LeaveEventButton from '../Buttons/LeaveEventButton/LeaveEventButton';
import { isMobile } from '../../utils';
@@ -31,6 +31,23 @@ const useStyles = makeStyles((theme: Theme) =>
[theme.breakpoints.down('sm')]: {
height: `${theme.mobileFooterHeight}px`,
padding: 0,
+ '& .MuiButton-textLabel': {
+ display: 'none',
+ },
+ '& .MuiButton-mobileBackground': {
+ margin: '0 0.5em',
+ padding: '12px',
+ borderRadius: '50%',
+ minWidth: 0,
+ height: '40px',
+ width: '40px',
+ '&:not(:last-child)': {
+ background: '#E1E3EA',
+ },
+ },
+ '& .MuiButton-startIcon': {
+ margin: 0,
+ },
},
},
screenShareBanner: {
@@ -90,14 +107,26 @@ export default function MenuBar() {
-
-
+
+
{!isSharingScreen && !isMobile && }
- {process.env.REACT_APP_DISABLE_TWILIO_CONVERSATIONS !== 'true' && }
-
+ {appState.isChatEnabled && }
+
+
+
+
+
+ {appState.participantType === 'host' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Move 'End/Leave Stream' button all the way to the right if on Desktop */}
diff --git a/apps/web/src/components/Player/Player.tsx b/apps/web/src/components/Player/Player.tsx
index 680b61d5..5123ff87 100644
--- a/apps/web/src/components/Player/Player.tsx
+++ b/apps/web/src/components/Player/Player.tsx
@@ -4,10 +4,12 @@ import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import { Player as TwilioPlayer } from '@twilio/live-player-sdk';
import PlayerMenuBar from './PlayerMenuBar/PlayerMenuBar';
import ParticipantWindow from '../ParticipantWindow/ParticipantWindow';
+import ChatWindow from '../ChatWindow/ChatWindow';
import usePlayerContext from '../../hooks/usePlayerContext/usePlayerContext';
import { useAppState } from '../../state';
import { useEnqueueSnackbar } from '../../hooks/useSnackbar/useSnackbar';
import { usePlayerState } from '../../hooks/usePlayerState/usePlayerState';
+import useChatContext from '../../hooks/useChatContext/useChatContext';
TwilioPlayer.telemetry.subscribe(data => {
const method = data.name === 'error' ? 'error' : 'log';
@@ -36,6 +38,7 @@ function Player() {
const classes = useStyles();
const videoElRef = useRef(null!);
const { player, disconnect } = usePlayerContext();
+ const { isChatWindowOpen } = useChatContext();
const state = usePlayerState();
const { appState, appDispatch } = useAppState();
const enqueueSnackbar = useEnqueueSnackbar();
@@ -44,7 +47,6 @@ function Player() {
useLayoutEffect(() => {
if (player && state === 'ready') {
appDispatch({ type: 'set-is-loading', isLoading: false });
-
player.attach(videoElRef.current);
player.play();
}
@@ -66,11 +68,12 @@ function Player() {
diff --git a/apps/web/src/components/Player/PlayerMenuBar/PlayerMenuBar.tsx b/apps/web/src/components/Player/PlayerMenuBar/PlayerMenuBar.tsx
index 353074c7..903bfbbe 100644
--- a/apps/web/src/components/Player/PlayerMenuBar/PlayerMenuBar.tsx
+++ b/apps/web/src/components/Player/PlayerMenuBar/PlayerMenuBar.tsx
@@ -1,12 +1,15 @@
import React, { useCallback, useRef, useState } from 'react';
+import clsx from 'clsx';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
-import { Typography, Grid, Button } from '@material-ui/core';
+import { Typography, Grid, Button, Hidden } from '@material-ui/core';
+import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { useAppState } from '../../../state';
import { raiseHand } from '../../../state/api/api';
import { useEnqueueSnackbar } from '../../../hooks/useSnackbar/useSnackbar';
import LowerHandIcon from '../../../icons/LowerHandIcon';
import RaiseHandIcon from '../../../icons/RaiseHandIcon';
import ParticipantIcon from '../../../icons/ParticipantIcon';
+import ChatIcon from '../../../icons/ChatIcon';
import useChatContext from '../../../hooks/useChatContext/useChatContext';
import useSyncContext from '../../../hooks/useSyncContext/useSyncContext';
@@ -25,8 +28,29 @@ const useStyles = makeStyles((theme: Theme) =>
[theme.breakpoints.down('sm')]: {
height: `${theme.mobileFooterHeight}px`,
padding: 0,
+ '& .MuiButton-startIcon': {
+ margin: 0,
+ },
},
},
+ mobileBackground: {
+ [theme.breakpoints.down('sm')]: {
+ marginRight: '1em',
+ padding: '12px',
+ borderRadius: '50%',
+ minWidth: 0,
+ height: '40px',
+ width: '40px',
+ '&:not(:last-child)': {
+ background: '#E1E3EA',
+ },
+ },
+ },
+ mobileRoomLabel: {
+ display: 'flex',
+ alignItems: 'center',
+ marginLeft: '0.5em',
+ },
button: {
background: theme.brand,
color: 'white',
@@ -40,6 +64,7 @@ const useStyles = makeStyles((theme: Theme) =>
export default function PlayerMenuBar({ roomName, disconnect }: { roomName?: string; disconnect: () => void }) {
const classes = useStyles();
const { appState, appDispatch } = useAppState();
+ const { setIsChatWindowOpen, isChatWindowOpen } = useChatContext();
const [isHandRaised, setIsHandRaised] = useState(false);
const lastClickTimeRef = useRef(0);
const enqueueSnackbar = useEnqueueSnackbar();
@@ -63,12 +88,21 @@ export default function PlayerMenuBar({ roomName, disconnect }: { roomName?: str
}, [isHandRaised, appState.participantName, appState.eventName, enqueueSnackbar]);
const toggleParticipantWindow = () => {
+ setIsChatWindowOpen(false);
appDispatch({
type: 'set-is-participant-window-open',
isParticipantWindowOpen: !appState.isParticipantWindowOpen,
});
};
+ const toggleChatWindow = () => {
+ setIsChatWindowOpen(!isChatWindowOpen);
+ appDispatch({
+ type: 'set-is-participant-window-open',
+ isParticipantWindowOpen: false,
+ });
+ };
+
const disconnectFromEvent = () => {
disconnect();
appDispatch({ type: 'reset-state' });
@@ -78,28 +112,57 @@ export default function PlayerMenuBar({ roomName, disconnect }: { roomName?: str
return (
-
-
- {roomName}
-
+
+
+ {roomName}
+
+
+
+
+
+ {roomName}
+
+
-
- : }>
- {isHandRaised ? 'Lower Hand' : 'Raise Hand'}
+
+ : }
+ >
+ {isHandRaised ? 'Lower Hand' : 'Raise Hand'}
-
toggleParticipantWindow()} startIcon={ }>
- Participants
+ toggleParticipantWindow()}
+ className={classes.mobileBackground}
+ startIcon={ }
+ >
+ Participants
+ {appState.isChatEnabled && (
+ toggleChatWindow()} className={classes.mobileBackground} startIcon={ }>
+ Chat
+
+ )}
+
+
+
+
+
+
-
-
-
- Leave Stream
-
+ {/* Move 'Leave Stream' button all the way to the right if on Desktop */}
+
+
+
+
+ Leave Stream
+
+
-
+
);
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: {