Skip to content

Commit

Permalink
Merge pull request #147 from twilio/feature/audience-chat
Browse files Browse the repository at this point in the history
  • Loading branch information
olipyskoty committed Jul 11, 2022
2 parents 601675f + f793002 commit 498f08c
Show file tree
Hide file tree
Showing 25 changed files with 366 additions and 214 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# TWILIO_REGION=stage

# Un-comment the following line to disable the Twilio Conversations functionality in the app.
# DISABLE_CHAT=true
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,7 +41,10 @@ export default function EndCallButton(props: { className?: string }) {

return (
<Button onClick={disconnect} className={clsx(classes.button, props.className)} data-cy-disconnect>
End Event
<Hidden smDown>End Event</Hidden>
<Hidden mdUp>
<ExitToAppIcon />
</Hidden>
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -53,9 +55,17 @@ export default function LeaveEventButton(props: { buttonClassName?: string }) {

return (
<>
<Button onClick={() => setMenuOpen(isOpen => !isOpen)} ref={anchorRef} className={classes.button}>
Leave Event
<ExpandMoreIcon />
<Button
onClick={() => setMenuOpen(isOpen => !isOpen)}
ref={anchorRef}
className={clsx(classes.button, 'MuiButton-mobileBackground')}
>
<Hidden smDown>
Leave Event <ExpandMoreIcon />
</Hidden>
<Hidden mdUp>
<ExitToAppIcon />
</Hidden>
</Button>
<MenuContainer
open={menuOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function ToggleAudioButton(props: { disabled?: boolean; className
startIcon={isAudioEnabled ? <MicIcon /> : <MicOffIcon />}
data-cy-audio-toggle
>
{!hasAudioTrack ? 'No Audio' : isAudioEnabled ? 'Mute' : 'Unmute'}
<span className="MuiButton-textLabel">{!hasAudioTrack ? 'No Audio' : isAudioEnabled ? 'Mute' : 'Unmute'}</span>
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,7 +78,6 @@ export default function ToggleChatButton() {
setTimeout(() => setShouldAnimate(false), ANIMATION_DURATION);
}
}, [shouldAnimate]);

useEffect(() => {
if (conversation && !isChatWindowOpen) {
const handleNewMessage = () => setShouldAnimate(true);
Expand All @@ -94,6 +93,7 @@ export default function ToggleChatButton() {
data-cy-chat-button
onClick={toggleChatWindow}
disabled={!conversation}
className="MuiButton-mobileBackground"
startIcon={
<div className={classes.iconContainer}>
<ChatIcon />
Expand All @@ -102,7 +102,7 @@ export default function ToggleChatButton() {
</div>
}
>
Chat
<Hidden smDown>Chat</Hidden>
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -96,6 +96,7 @@ export default function ToggleParticipantWindowButton() {

return (
<Button
className="MuiButton-mobileBackground"
onClick={toggleParticipantWindow}
startIcon={
<div className={classes.iconContainer}>
Expand All @@ -109,7 +110,7 @@ export default function ToggleParticipantWindowButton() {
</div>
}
>
Participants
<Hidden smDown>Participants</Hidden>
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default function ToggleVideoButton(props: { disabled?: boolean; className
disabled={!hasVideoInputDevices || props.disabled}
startIcon={isVideoEnabled ? <VideoOnIcon /> : <VideoOffIcon />}
>
{!hasVideoInputDevices ? 'No Video' : isVideoEnabled ? 'Stop Video' : 'Start Video'}
<span className="MuiButton-textLabel">
{!hasVideoInputDevices ? 'No Video' : isVideoEnabled ? 'Stop Video' : 'Start Video'}
</span>
</Button>
);
}
15 changes: 9 additions & 6 deletions apps/web/src/components/ChatProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -17,18 +17,21 @@ type ChatContextType = {
export const ChatContext = createContext<ChatContextType>(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<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [hasUnreadMessages, setHasUnreadMessages] = useState(false);
const [videoRoomSid, setVideoRoomSid] = useState('');
const [chatClient, setChatClient] = useState<Client>();

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` };
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
<ChatContext.Provider
Expand Down
60 changes: 1 addition & 59 deletions apps/web/src/components/ChatWindow/ChatInput/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, CircularProgress, Grid, makeStyles } from '@material-ui/core';
import { Button, Grid, makeStyles } from '@material-ui/core';
import clsx from 'clsx';
import { Conversation } from '@twilio/conversations/lib/conversation';
import FileAttachmentIcon from '../../../icons/FileAttachmentIcon';
import { isMobile } from '../../../utils';
import SendMessageIcon from '../../../icons/SendMessageIcon';
import Snackbar from '../../Snackbar/Snackbar';
Expand Down Expand Up @@ -36,17 +35,6 @@ const useStyles = makeStyles(theme => ({
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',
Expand All @@ -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<string | null>(null);
const isValidMessage = /\S/.test(messageBody);
const textInputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isTextareaFocused, setIsTextareaFocused] = useState(false);

useEffect(() => {
Expand Down Expand Up @@ -104,29 +87,6 @@ export default function ChatInput({ conversation, isChatWindowOpen }: ChatInputP
}
};

const handleSendFile = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={classes.chatInputContainer}>
<Snackbar
Expand Down Expand Up @@ -159,25 +119,7 @@ export default function ChatInput({ conversation, isChatWindowOpen }: ChatInputP
</div>

<Grid container alignItems="flex-end" justifyContent="flex-end" wrap="nowrap">
{/* 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. */}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={handleSendFile}
value={''}
accept={ALLOWED_FILE_TYPES}
/>
<div className={classes.buttonContainer}>
<div className={classes.fileButtonContainer}>
<Button className={classes.button} onClick={() => fileInputRef.current?.click()} disabled={isSendingFile}>
<FileAttachmentIcon />
</Button>

{isSendingFile && <CircularProgress size={24} className={classes.fileButtonLoadingSpinner} />}
</div>

<Button
className={classes.button}
onClick={() => handleSendMessage(messageBody)}
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/ChatWindow/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/ChatWindow/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -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 (
<MessageListScrollContainer messages={messages}>
{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 (
<React.Fragment key={message.sid}>
{shouldDisplayMessageInfo && (
Expand Down
Loading

0 comments on commit 498f08c

Please sign in to comment.