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)}
+ />
onJoin(roomName, 'create', duration, privacy)}
+ onClick={() => onJoin(slugify.convert(roomName), 'create', duration, privacy)}
>
Create class
@@ -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