diff --git a/custom/fitness-demo/components/App/App.js b/custom/fitness-demo/components/App/App.js index 81b43d9..a0b0d71 100644 --- a/custom/fitness-demo/components/App/App.js +++ b/custom/fitness-demo/components/App/App.js @@ -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 = () => ( + + + +); - const componentForState = useCallUI({ - state, - room: , - ...customComponentForState, - }); - - // Memoize children to avoid unnecassary renders from HOC - return useMemo( - () => ( - <> - {roomExp && } -
- {componentForState()} - - - -
- - ), - [componentForState, roomExp] - ); -}; - -App.propTypes = { - customComponentForState: PropTypes.any, -}; - -export default App; +export default CustomApp; diff --git a/custom/fitness-demo/components/Call/ChatAside.js b/custom/fitness-demo/components/Call/ChatAside.js new file mode 100644 index 0000000..706cb12 --- /dev/null +++ b/custom/fitness-demo/components/Call/ChatAside.js @@ -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 ( + + ); +}; + +export default ChatAside; diff --git a/custom/fitness-demo/components/Call/VideoGrid.js b/custom/fitness-demo/components/Call/VideoGrid.js index e71ff6d..7a3fddc 100644 --- a/custom/fitness-demo/components/Call/VideoGrid.js +++ b/custom/fitness-demo/components/Call/VideoGrid.js @@ -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) => ( )), - [layout, participants] + [layout, allParticipants] ); - if (!participants.length) { + if (!allParticipants.length) { return null; } diff --git a/custom/fitness-demo/components/Prejoin/Intro.js b/custom/fitness-demo/components/Prejoin/Intro.js index baed4ee..1fc4b4f 100644 --- a/custom/fitness-demo/components/Prejoin/Intro.js +++ b/custom/fitness-demo/components/Prejoin/Intro.js @@ -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 => (
-
{room}
- {`${rooms[room].length} people in class`} +
{slugify.revert(room)}
+ + {`${rooms[room].length} ${rooms[room].length > 1 ? 'people' : 'person'} in class`} +
-
@@ -88,7 +94,7 @@ export const Intro = ({ setRoomName(e.target.value)} required @@ -104,13 +110,16 @@ export const Intro = ({ - setPrivacy(e.target.checked)} /> + setPrivacy(e.target.checked)} + /> @@ -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 { diff --git a/custom/fitness-demo/components/Tray/Chat.js b/custom/fitness-demo/components/Tray/Chat.js new file mode 100644 index 0000000..52826e8 --- /dev/null +++ b/custom/fitness-demo/components/Tray/Chat.js @@ -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 ( + toggleAside(CHAT_ASIDE)} + > + + + ); +}; + +export default ChatTray; diff --git a/custom/fitness-demo/components/Tray/ScreenShare.js b/custom/fitness-demo/components/Tray/ScreenShare.js new file mode 100644 index 0000000..d7a3f8d --- /dev/null +++ b/custom/fitness-demo/components/Tray/ScreenShare.js @@ -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 ( + + + + ); +}; + +export default ScreenShareTray; diff --git a/custom/fitness-demo/components/Tray/index.js b/custom/fitness-demo/components/Tray/index.js new file mode 100644 index 0000000..01d94c2 --- /dev/null +++ b/custom/fitness-demo/components/Tray/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import ChatTray from './Chat'; +import ScreenShareTray from './ScreenShare'; + +export const Tray = () => { + return ( + <> + + + + ); +}; + +export default Tray; diff --git a/custom/fitness-demo/contexts/ChatProvider.js b/custom/fitness-demo/contexts/ChatProvider.js new file mode 100644 index 0000000..ccb428b --- /dev/null +++ b/custom/fitness-demo/contexts/ChatProvider.js @@ -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 ( + + {children} + + ); +}; + +ChatProvider.propTypes = { + children: PropTypes.node, +}; + +export const useChat = () => useContext(ChatContext); diff --git a/custom/fitness-demo/hooks/useMessageSound.js b/custom/fitness-demo/hooks/useMessageSound.js new file mode 100644 index 0000000..7449e10 --- /dev/null +++ b/custom/fitness-demo/hooks/useMessageSound.js @@ -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; diff --git a/custom/fitness-demo/next.config.js b/custom/fitness-demo/next.config.js index 81f29cd..9140e28 100644 --- a/custom/fitness-demo/next.config.js +++ b/custom/fitness-demo/next.config.js @@ -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'); diff --git a/custom/fitness-demo/pages/_app.js b/custom/fitness-demo/pages/_app.js index d8a7a82..24eab68 100644 --- a/custom/fitness-demo/pages/_app.js +++ b/custom/fitness-demo/pages/_app.js @@ -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 = ; +App.customAppComponent = ; export default App; diff --git a/custom/shared/components/Aside/PeopleAside.js b/custom/shared/components/Aside/PeopleAside.js index d5dc374..feb7f12 100644 --- a/custom/shared/components/Aside/PeopleAside.js +++ b/custom/shared/components/Aside/PeopleAside.js @@ -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 = () => { )}
- {allParticipants.map((p) => ( + {participants.map((p) => ( ))}
diff --git a/custom/shared/components/Button/Button.js b/custom/shared/components/Button/Button.js index 9bca12d..65114c1 100644 --- a/custom/shared/components/Button/Button.js +++ b/custom/shared/components/Button/Button.js @@ -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; diff --git a/custom/shared/contexts/CallProvider.js b/custom/shared/contexts/CallProvider.js index 5e8ad4d..fb01eda 100644 --- a/custom/shared/contexts/CallProvider.js +++ b/custom/shared/contexts/CallProvider.js @@ -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, }} diff --git a/custom/shared/icons/share-sm.svg b/custom/shared/icons/share-sm.svg new file mode 100644 index 0000000..16effb7 --- /dev/null +++ b/custom/shared/icons/share-sm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/custom/shared/lib/slugify.js b/custom/shared/lib/slugify.js new file mode 100644 index 0000000..f83370d --- /dev/null +++ b/custom/shared/lib/slugify.js @@ -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 }; \ No newline at end of file