Add chat and screenshare options to the demo

This commit is contained in:
harshithpabbati 2021-12-29 22:11:37 +05:30
parent ec770a189a
commit 209b9bd72e
16 changed files with 443 additions and 70 deletions

View File

@ -1,51 +1,13 @@
import React, { useMemo } from 'react';
import ExpiryTimer from '@custom/shared/components/ExpiryTimer';
import { useCallState } from '@custom/shared/contexts/CallProvider';
import { useCallUI } from '@custom/shared/hooks/useCallUI';
import React from 'react';
import PropTypes from 'prop-types';
import Room from '../Call/Room';
import { Asides } from './Asides';
import { Modals } from './Modals';
import App from '@custom/basic-call/components/App';
import { ChatProvider } from '../../contexts/ChatProvider';
export const App = ({ customComponentForState }) => {
const { roomExp, state } = useCallState();
// Extend our basic call app component with the chat context
export const CustomApp = () => (
<ChatProvider>
<App />
</ChatProvider>
);
const componentForState = useCallUI({
state,
room: <Room />,
...customComponentForState,
});
// Memoize children to avoid unnecassary renders from HOC
return useMemo(
() => (
<>
{roomExp && <ExpiryTimer expiry={roomExp} />}
<div className="app">
{componentForState()}
<Modals />
<Asides />
<style jsx>{`
color: white;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loader {
margin: 0 auto;
}
`}</style>
</div>
</>
),
[componentForState, roomExp]
);
};
App.propTypes = {
customComponentForState: PropTypes.any,
};
export default App;
export default CustomApp;

View File

@ -0,0 +1,136 @@
import React, { useEffect, useRef, useState } from 'react';
import { Aside } from '@custom/shared/components/Aside';
import Button from '@custom/shared/components/Button';
import { TextInput } from '@custom/shared/components/Input';
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
import { useChat } from '../../contexts/ChatProvider';
import { useMessageSound } from '../../hooks/useMessageSound';
export const CHAT_ASIDE = 'chat';
export const ChatAside = () => {
const { showAside, setShowAside } = useUIState();
const { sendMessage, chatHistory, hasNewMessages, setHasNewMessages } =
useChat();
const { localParticipant } = useParticipants();
const [newMessage, setNewMessage] = useState('');
const playMessageSound = useMessageSound();
const chatWindowRef = useRef();
useEffect(() => {
// Clear out any new message notifications if we're showing the chat screen
if (showAside === CHAT_ASIDE) {
setHasNewMessages(false);
}
}, [showAside, chatHistory.length, setHasNewMessages]);
useEffect(() => {
if (hasNewMessages && showAside !== CHAT_ASIDE) {
playMessageSound();
}
}, [playMessageSound, showAside, hasNewMessages]);
useEffect(() => {
if (chatWindowRef.current) {
chatWindowRef.current.scrollTop = chatWindowRef.current.scrollHeight;
}
}, [chatHistory?.length]);
const isLocalUser = (id) => id === localParticipant.user_id;
if (!showAside || showAside !== CHAT_ASIDE) {
return null;
}
return (
<Aside onClose={() => setShowAside(false)}>
<div className="messages-container" ref={chatWindowRef}>
{chatHistory.map((chatItem) => (
<div
className={isLocalUser(chatItem.senderID) ? 'message local' : 'message'}
key={chatItem.id}
>
<span className="content">{chatItem.message}</span>
<span className="sender">{chatItem.sender}</span>
</div>
))}
</div>
<footer className="chat-footer">
<TextInput
value={newMessage}
placeholder="Type message here"
variant="transparent"
onChange={(e) => setNewMessage(e.target.value)}
/>
<Button
className="send-button"
variant="transparent"
disabled={!newMessage}
onClick={() => {
sendMessage(newMessage);
setNewMessage('');
}}
>
Send
</Button>
</footer>
<style jsx>{`
.messages-container {
flex: 1;
overflow-y: scroll;
}
.message {
margin: var(--spacing-xxs);
padding: var(--spacing-xxs);
background: var(--gray-wash);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.message.local {
background: var(--gray-light);
}
.message.local .sender {
color: var(--primary-dark);
}
.content {
color: var(--text-mid);
display: block;
}
.sender {
font-weight: var(--weight-medium);
font-size: 0.75rem;
}
.chat-footer {
flex-flow: row nowrap;
box-sizing: border-box;
padding: var(--spacing-xxs) 0;
display: flex;
position: relative;
border-top: 1px solid var(--gray-light);
}
.chat-footer :global(.input-container) {
flex: 1;
}
.chat-footer :global(.input-container input) {
padding-right: 0px;
}
.chat-footer :global(.send-button) {
padding: 0 var(--spacing-xs);
}
`}</style>
</Aside>
);
};
export default ChatAside;

View File

@ -18,7 +18,7 @@ import { useDeepCompareMemo } from 'use-deep-compare';
export const VideoGrid = React.memo(
() => {
const containerRef = useRef();
const { participants } = useParticipants();
const { allParticipants } = useParticipants();
const [dimensions, setDimensions] = useState({
width: 1,
height: 1,
@ -48,7 +48,7 @@ export const VideoGrid = React.memo(
// Basic brute-force packing algo
const layout = useMemo(() => {
const aspectRatio = DEFAULT_ASPECT_RATIO;
const tileCount = participants.length || 0;
const tileCount = allParticipants.length || 0;
const w = dimensions.width;
const h = dimensions.height;
@ -87,23 +87,23 @@ export const VideoGrid = React.memo(
}
return bestLayout;
}, [dimensions, participants]);
}, [dimensions, allParticipants]);
// Memoize our tile list to avoid unnecassary re-renders
const tiles = useDeepCompareMemo(
() =>
participants.map((p) => (
allParticipants.map((p) => (
<Tile
participant={p}
key={p.id}
mirrored
mirrored={!p.isScreenshare}
style={{ maxWidth: layout.width, maxHeight: layout.height }}
/>
)),
[layout, participants]
[layout, allParticipants]
);
if (!participants.length) {
if (!allParticipants.length) {
return null;
}

View File

@ -9,6 +9,7 @@ import {
import Field from '@custom/shared/components/Field';
import { TextInput, BooleanInput, SelectInput } from '@custom/shared/components/Input';
import Well from '@custom/shared/components/Well';
import { slugify } from '@custom/shared/lib/slugify';
import PropTypes from 'prop-types';
/**
@ -24,7 +25,7 @@ export const Intro = ({
const [rooms, setRooms] = useState({});
const [duration, setDuration] = useState("30");
const [roomName, setRoomName] = useState();
const [privacy, setPrivacy] = useState(false);
const [privacy, setPrivacy] = useState(true);
const fetchRooms = async () => {
const res = await fetch('/api/presence', {
@ -62,12 +63,17 @@ export const Intro = ({
{Object.keys(rooms).map(room => (
<div className="room" key={room}>
<div>
<div className="label">{room}</div>
<span>{`${rooms[room].length} people in class`}</span>
<div className="label">{slugify.revert(room)}</div>
<span>
{`${rooms[room].length} ${rooms[room].length > 1 ? 'people' : 'person'} in class`}
</span>
</div>
<div className="join-room">
<Button variant="dark" size="tiny" onClick={() => onJoin(room, 'join')}>
Join Room
<Button
variant="gray"
size="tiny"
onClick={() => onJoin(slugify.convert(room), 'join')}>
Join Class
</Button>
</div>
</div>
@ -88,7 +94,7 @@ export const Intro = ({
<Field label="Give you a class name">
<TextInput
type="text"
placeholder="Eg. super-stretch"
placeholder="Eg. Super stretchy morning flow"
defaultValue={roomName}
onChange={(e) => setRoomName(e.target.value)}
required
@ -104,13 +110,16 @@ export const Intro = ({
</SelectInput>
</Field>
<Field label="Public (anyone can join)">
<BooleanInput value={privacy} onChange={e => setPrivacy(e.target.checked)} />
<BooleanInput
value={privacy}
onChange={e => setPrivacy(e.target.checked)}
/>
</Field>
</CardBody>
<CardFooter divider>
<Button
fullWidth
onClick={() => onJoin(roomName, 'create', duration, privacy)}
onClick={() => onJoin(slugify.convert(roomName), 'create', duration, privacy)}
>
Create class
</Button>
@ -131,7 +140,7 @@ export const Intro = ({
display: flex;
width: 25vw;
border-bottom: 1px solid var(--gray-light);
padding-bottom: var(--spacing-xxs);
padding: var(--spacing-xxs) 0;
gap: 10px;
}
.room .label {

View File

@ -0,0 +1,24 @@
import React from 'react';
import { TrayButton } from '@custom/shared/components/Tray';
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
import { ReactComponent as IconChat } from '@custom/shared/icons/chat-md.svg';
import { useChat } from '../../contexts/ChatProvider';
import { CHAT_ASIDE } from '../Call/ChatAside';
export const ChatTray = () => {
const { toggleAside } = useUIState();
const { hasNewMessages } = useChat();
return (
<TrayButton
label="Chat"
bubble={hasNewMessages}
onClick={() => toggleAside(CHAT_ASIDE)}
>
<IconChat />
</TrayButton>
);
};
export default ChatTray;

View File

@ -0,0 +1,43 @@
import React, { useMemo } from 'react';
import { TrayButton } from '@custom/shared/components/Tray';
import { useCallState } from '@custom/shared/contexts/CallProvider';
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
import { ReactComponent as IconShare } from '@custom/shared/icons/share-sm.svg';
const MAX_SCREEN_SHARES = 2;
export const ScreenShareTray = () => {
const { callObject, enableScreenShare } = useCallState();
const { screens, participants } = useParticipants();
const isSharingScreen = useMemo(
() => screens.some((s) => s.isLocal),
[screens]
);
const screensLength = useMemo(() => screens.length, [screens]);
const toggleScreenShare = () =>
isSharingScreen ? callObject.stopScreenShare() : callObject.startScreenShare();
const disabled =
participants.length &&
screensLength >= MAX_SCREEN_SHARES &&
!isSharingScreen;
if (!enableScreenShare) return null;
return (
<TrayButton
label={isSharingScreen ? 'Stop': 'Share'}
orange={isSharingScreen}
disabled={disabled}
onClick={toggleScreenShare}
>
<IconShare />
</TrayButton>
);
};
export default ScreenShareTray;

View File

@ -0,0 +1,14 @@
import React from 'react';
import ChatTray from './Chat';
import ScreenShareTray from './ScreenShare';
export const Tray = () => {
return (
<>
<ChatTray />
<ScreenShareTray />
</>
);
};
export default Tray;

View File

@ -0,0 +1,104 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { useCallState } from '@custom/shared/contexts/CallProvider';
import { useSharedState } from '@custom/shared/hooks/useSharedState';
import { nanoid } from 'nanoid';
import PropTypes from 'prop-types';
export const ChatContext = createContext();
export const ChatProvider = ({ children }) => {
const { callObject } = useCallState();
const { sharedState, setSharedState } = useSharedState({
initialValues: {
chatHistory: [],
},
broadcast: false,
});
const [hasNewMessages, setHasNewMessages] = useState(false);
const handleNewMessage = useCallback(
(e) => {
if (e?.data?.message?.type) return;
const participants = callObject.participants();
const sender = participants[e.fromId].user_name
? participants[e.fromId].user_name
: 'Guest';
setSharedState(values => ({
...values,
chatHistory: [
...values.chatHistory,
// nanoid - we use it to generate unique ID string
{ sender, senderID: e.fromId, message: e.data.message, id: nanoid() },
]
}));
setHasNewMessages(true);
},
[callObject, setSharedState]
);
const sendMessage = useCallback(
(message) => {
if (!callObject) return;
console.log('💬 Sending app message');
callObject.sendAppMessage({ message }, '*');
const participants = callObject.participants();
// Get the sender (local participant) name
const sender = participants.local.user_name
? participants.local.user_name
: 'Guest';
const senderID = participants.local.user_id;
// Update shared state chat history
return setSharedState(values => ({
...values,
chatHistory: [
...values.chatHistory,
// nanoid - we use it to generate unique ID string
{ sender, senderID, message, id: nanoid() }
]
}));
},
[callObject, setSharedState]
);
useEffect(() => {
if (!callObject) return;
console.log(`💬 Chat provider listening for app messages`);
callObject.on('app-message', handleNewMessage);
return () => callObject.off('app-message', handleNewMessage);
}, [callObject, handleNewMessage]);
return (
<ChatContext.Provider
value={{
sendMessage,
chatHistory: sharedState.chatHistory,
hasNewMessages,
setHasNewMessages,
}}
>
{children}
</ChatContext.Provider>
);
};
ChatProvider.propTypes = {
children: PropTypes.node,
};
export const useChat = () => useContext(ChatContext);

View File

@ -0,0 +1,19 @@
import { useEffect, useMemo } from 'react';
import { useSound } from '@custom/shared/hooks/useSound';
import { debounce } from 'debounce';
/**
* Convenience hook to play `join.mp3` when participants join the call
*/
export const useMessageSound = () => {
const { load, play } = useSound('assets/message.mp3');
useEffect(() => {
load();
}, [load]);
return useMemo(() => debounce(() => play(), 5000, true), [play]);
};
export default useMessageSound;

View File

@ -1,5 +1,8 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')(['@custom/shared']);
const withTM = require('next-transpile-modules')([
'@custom/shared',
'@custom/basic-call',
]);
const packageJson = require('./package.json');

View File

@ -2,6 +2,9 @@ import React from 'react';
import GlobalStyle from '@custom/shared/components/GlobalStyle';
import Head from 'next/head';
import PropTypes from 'prop-types';
import { CustomApp } from '../components/App/App';
import ChatAside from '../components/Call/ChatAside';
import Tray from '../components/Tray';
function App({ Component, pageProps }) {
return (
@ -31,9 +34,9 @@ App.propTypes = {
pageProps: PropTypes.object,
};
App.asides = [];
App.asides = [ChatAside];
App.modals = [];
App.customTrayComponent = null;
App.customAppComponent = null;
App.customTrayComponent = <Tray />;
App.customAppComponent = <CustomApp />;
export default App;

View File

@ -97,7 +97,7 @@ PersonRow.propTypes = {
export const PeopleAside = () => {
const { callObject } = useCallState();
const { showAside, setShowAside } = useUIState();
const { allParticipants, isOwner } = useParticipants();
const { participants, isOwner } = useParticipants();
if (!showAside || showAside !== PEOPLE_ASIDE) {
return null;
@ -131,7 +131,7 @@ export const PeopleAside = () => {
</div>
)}
<div className="rows">
{allParticipants.map((p) => (
{participants.map((p) => (
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
))}
</div>

View File

@ -289,6 +289,24 @@ export const Button = forwardRef(
.button.dark:disabled {
opacity: 0.35;
}
.button.gray {
background: ${theme.gray.light};
color: ${theme.gray.dark};
border: 0;
}
.button.gray:hover,
.button.gray:focus,
.button.gray:active {
background: ${theme.gray.default};
border: 0;
}
.button.gray:focus {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
}
.button.gray:disabled {
opacity: 0.35;
}
.button.white {
background: white;

View File

@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
} from 'react';
import DailyIframe from '@daily-co/daily-js';
import Bowser from 'bowser';
import PropTypes from 'prop-types';
import {
@ -31,6 +32,7 @@ export const CallProvider = ({
token = '',
subscribeToTracksAutomatically = true,
}) => {
const [enableScreenShare, setEnableScreenShare] = useState(false);
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
const [showLocalVideo, setShowLocalVideo] = useState(true);
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
@ -52,7 +54,12 @@ export const CallProvider = ({
if (!daily) return;
const updateRoomConfigState = async () => {
const roomConfig = await daily.room();
const isOob = !!roomConfig.config?.owner_only_broadcast;
const owner = roomConfig.tokenConfig?.is_owner;
const config = roomConfig?.config;
const fullUI = !isOob || (isOob && owner);
if (!config) return;
if (config.exp) {
@ -76,6 +83,12 @@ export const CallProvider = ({
roomConfig?.tokenConfig?.start_cloud_recording ?? false
);
}
setEnableScreenShare(
fullUI &&
(roomConfig?.tokenConfig?.enable_screenshare ??
roomConfig?.config?.enable_screenshare) &&
DailyIframe.supportedBrowser().supportsScreenShare
);
};
updateRoomConfigState();
}, [state, daily]);
@ -115,11 +128,13 @@ export const CallProvider = ({
showLocalVideo,
roomExp,
enableRecording,
enableScreenShare,
videoQuality,
setVideoQuality,
setBandwidth,
setRedirectOnLeave,
setShowLocalVideo,
setEnableScreenShare,
startCloudRecording,
subscribeToTracksAutomatically,
}}

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 22H6" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 2L8 9" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 2H23V18H1V12" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 2H1V8" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@ -0,0 +1,17 @@
const convert = (keyword) => {
return keyword
.toString()
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
};
const revert = (keyword) => {
return keyword
.toString()
.trim()
.toLowerCase()
.replace('-', ' ')
}
export const slugify = { convert, revert };