Merge pull request #15 from daily-demos/dailyjs/text-chat

Text chat demo & basic call abstraction
This commit is contained in:
Jon Taylor 2021-06-23 18:04:53 +01:00 committed by GitHub
commit abf4b8ee08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 3648 additions and 180 deletions

View File

@ -1,31 +1,29 @@
# Daily JS Examples
Run an examples via `yarn workspace @dailyjs/basic-call dev` (replacing `basic-call` with the name of the demo) from the project root
### [🤙 Basic call](./basic-call)
Note: please ensure your rooms are setup to use [web sockets](https://docs.daily.co/reference#domain-configuration)
The basic call demo (derived from our prebuilt UI codebase) demonstrates how to create a video and audio call using Call Object mode.
Note: examples are served using [nextjs](https://nextjs.org/)
### [💬 Text chat](./text-chat)
Send messages to other participants using sendAppMessage
---
## Getting started
```
// run locally, from project root
yarn
yarn workspace @dailyjs/[example-to-run] dev
```
We recommend starting with the [basic call](./basic-call) example, showcasing the common flow of a call Daily call, device management and error handling.
Run an examples with `yarn workspace @dailyjs/[demo-name] dev` (replacing `[demo-name]` with the name of the demo you'd like to run e.g. `basic-call`.
- Please ensure your Daily rooms are setup to use [web sockets](https://docs.daily.co/reference#domain-configuration)
- Follow the instructions within each demo first, making sure to set all the necassary local environment variables etc
- Examples are served using [nextjs](https://nextjs.org/)
## Shared code
These examples re-use some common components, contexts, hooks and libraries. These can be found in the [shared](./shared) folder.
---
## Deploy your own on Vercel
## Where to get started?
### [🤙 Basic call](./basic-call)
The basic call demo (derived from our prebuilt UI codebase) demonstrates how to create a video and audio call using Call Object mode.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -2,6 +2,12 @@
![Basic Call](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-basic-call.vercel.app)**
---
## What does this demo do?
- Built on [NextJS](https://nextjs.org/)
@ -18,7 +24,7 @@ Please note: this demo is not currently mobile optimised
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example env.local
mv env.example .env.local
# from project root...
yarn
@ -44,8 +50,6 @@ Abstraction hook that manages Daily call state and error handling
**[ParticipantProvider.js](../shared/contexts/ParticipantProvider.js)**
Manages participant state and abstracts common selectors / derived data
## Deploy your own
## Deploy your own on Vercel
Deploy the example using Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples%2Ftree%2Fmain%2Fdailyjs%2Fbasic-call&env=DAILY_DOMAIN,DAILY_API_KEY&project-name=dailyjs-basic-call&demo-title=Daily%20Basic%20Call%20Demo)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -2,16 +2,17 @@ import React, { useMemo } from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useCallUI } from '@dailyjs/shared/hooks/useCallUI';
import PropTypes from 'prop-types';
import Room from '../Room';
import { Asides } from './Asides';
import { Modals } from './Modals';
export const App = () => {
const { state, leave } = useCallState();
const { state } = useCallState();
const componentForState = useCallUI({
state,
room: () => <Room onLeave={() => leave()} />,
room: () => <Room />,
});
// Memoize children to avoid unnecassary renders from HOC
@ -38,4 +39,8 @@ export const App = () => {
);
};
App.propTypes = {
asides: PropTypes.arrayOf(PropTypes.func),
};
export default App;

View File

@ -1,10 +1,18 @@
import React from 'react';
import { PeopleAside } from '@dailyjs/shared/components/Aside';
import { PeopleAside } from '@dailyjs/shared/components/Aside/PeopleAside';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
export const Asides = () => (
<>
<PeopleAside />
</>
);
export const Asides = () => {
const { asides } = useUIState();
return (
<>
<PeopleAside />
{asides.map((AsideComponent) => (
<AsideComponent key={AsideComponent.name} />
))}
</>
);
};
export default Asides;

View File

@ -16,7 +16,14 @@ import PropTypes from 'prop-types';
* ---
* Specify which room we would like to join
*/
export const Intro = ({ room, error, domain, onJoin, fetching = false }) => {
export const Intro = ({
room,
error,
domain,
onJoin,
title,
fetching = false,
}) => {
const [roomName, setRoomName] = useState();
const [owner, setOwner] = useState(false);
const [fetchToken, setFetchToken] = useState(false);
@ -27,7 +34,7 @@ export const Intro = ({ room, error, domain, onJoin, fetching = false }) => {
return (
<Card>
<CardHeader>Daily Basic Call Example</CardHeader>
<CardHeader>{title}</CardHeader>
<CardBody>
{error && (
<Well variant="error">
@ -67,6 +74,7 @@ export const Intro = ({ room, error, domain, onJoin, fetching = false }) => {
Intro.propTypes = {
room: PropTypes.string,
title: PropTypes.string,
error: PropTypes.string,
domain: PropTypes.string.isRequired,
onJoin: PropTypes.func.isRequired,

View File

@ -1,47 +1,23 @@
import React from 'react';
import { Audio } from '@dailyjs/shared/components/Audio';
import { BasicTray } from '@dailyjs/shared/components/Tray';
import {
WaitingRoomModal,
WaitingRoomNotification,
} from '@dailyjs/shared/components/WaitingRoom';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { useWaitingRoom } from '@dailyjs/shared/contexts/WaitingRoomProvider';
import useJoinSound from '@dailyjs/shared/hooks/useJoinSound';
import { ReactComponent as IconCameraOff } from '@dailyjs/shared/icons/camera-off-md.svg';
import { ReactComponent as IconCameraOn } from '@dailyjs/shared/icons/camera-on-md.svg';
import { ReactComponent as IconLeave } from '@dailyjs/shared/icons/leave-md.svg';
import { ReactComponent as IconMicOff } from '@dailyjs/shared/icons/mic-off-md.svg';
import { ReactComponent as IconMicOn } from '@dailyjs/shared/icons/mic-on-md.svg';
import { ReactComponent as IconPeople } from '@dailyjs/shared/icons/people-md.svg';
import { ReactComponent as IconSettings } from '@dailyjs/shared/icons/settings-md.svg';
import PropTypes from 'prop-types';
import { VideoGrid } from '../VideoGrid';
import { Header } from './Header';
import { Tray, TrayButton } from './Tray';
export const Room = ({ onLeave }) => {
const { callObject } = useCallState();
const { setShowDeviceModal, setShowPeopleAside } = useUIState();
const { isCamMuted, isMicMuted } = useMediaDevices();
export const Room = () => {
const { setShowModal, showModal } = useWaitingRoom();
const { localParticipant } = useParticipants();
useJoinSound();
const toggleCamera = (newState) => {
if (!callObject) return false;
return callObject.setLocalVideo(newState);
};
const toggleMic = (newState) => {
if (!callObject) return false;
return callObject.setLocalAudio(newState);
};
return (
<div className="room">
<Header />
@ -60,39 +36,7 @@ export const Room = ({ onLeave }) => {
</>
)}
<Tray>
<TrayButton
label="Camera"
onClick={() => toggleCamera(isCamMuted)}
orange={isCamMuted}
>
{isCamMuted ? <IconCameraOff /> : <IconCameraOn />}
</TrayButton>
<TrayButton
label="Mic"
onClick={() => toggleMic(isMicMuted)}
orange={isMicMuted}
>
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
</TrayButton>
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
<IconSettings />
</TrayButton>
<TrayButton
label="People"
onClick={() => setShowPeopleAside((p) => !p)}
>
<IconPeople />
</TrayButton>
<span className="divider" />
<TrayButton label="Leave" onClick={onLeave} orange>
<IconLeave />
</TrayButton>
</Tray>
<BasicTray />
<Audio />
<style jsx>{`
@ -117,8 +61,4 @@ export const Room = ({ onLeave }) => {
);
};
Room.propTypes = {
onLeave: PropTypes.func.isRequired,
};
export default Room;

View File

@ -1,65 +0,0 @@
import React from 'react';
import Button from '@dailyjs/shared/components/Button';
import PropTypes from 'prop-types';
export const TrayButton = ({ children, label, onClick, orange = false }) => (
<div className={orange ? 'tray-button orange' : 'tray-button'}>
<Button onClick={() => onClick()} variant="dark" size="large-square">
{children}
</Button>
<span>{label}</span>
<style jsx>{`
.tray-button {
text-align: center;
user-select: none;
}
.tray-button.orange :global(.button) {
color: var(--secondary-dark);
}
span {
color: white;
font-weight: var(--weight-medium);
font-size: 12px;
}
`}</style>
</div>
);
TrayButton.propTypes = {
children: PropTypes.node,
onClick: PropTypes.func,
orange: PropTypes.bool,
label: PropTypes.string.isRequired,
};
export const Tray = ({ children }) => (
<footer>
{children}
<style jsx>{`
footer {
flex: 0 0 auto;
padding: var(--spacing-xs);
box-sizing: border-box;
width: 100%;
display: flex;
justify-content: center;
gap: var(--spacing-xxs);
}
footer :global(.divider) {
width: 1px;
height: 52px;
background: rgba(255, 255, 255, 0.1);
}
`}</style>
</footer>
);
Tray.propTypes = {
children: PropTypes.node,
};
export default Tray;

View File

@ -0,0 +1 @@
// Note: I am here because next-transpile-modules requires a mainfile

View File

@ -1,4 +1,10 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')(['@dailyjs/shared']);
module.exports = withPlugins([withTM]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -1,5 +1,6 @@
{
"name": "@dailyjs/basic-call",
"description": "Basic Call Example",
"version": "0.1.0",
"private": true,
"scripts": {
@ -18,5 +19,8 @@
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
},
"engines": {
"node": ">=0.12"
}
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import GlobalHead from '@dailyjs/shared/components/GlobalHead';
import GlobalStyle from '@dailyjs/shared/components/GlobalStyle';
import Head from 'next/head';
import PropTypes from 'prop-types';
@ -7,15 +8,16 @@ function App({ Component, pageProps }) {
return (
<>
<Head>
<title>Daily - Basic Call Example</title>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Daily - {process.env.PROJECT_TITLE}</title>
</Head>
<GlobalHead />
<GlobalStyle />
<Component {...pageProps} />
<Component
asides={App.asides}
customTrayComponent={App.customTrayComponent}
customAppComponent={App.customAppComponent}
{...pageProps}
/>
</>
);
}
@ -30,4 +32,8 @@ App.propTypes = {
pageProps: PropTypes.object,
};
App.asides = [];
App.customTrayComponent = null;
App.customAppComponent = null;
export default App;

View File

@ -21,7 +21,9 @@ export default async function handler(req, res) {
};
const dailyRes = await fetch(
`${process.env.DAILY_REST_DOMAIN}/meeting-tokens`,
`${
process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'
}/meeting-tokens`,
options
);

View File

@ -17,7 +17,13 @@ import { Intro, NotConfigured } from '../components/Intro';
* - Set call owner status
* - Finally, renders the main application loop
*/
export default function Index({ domain, isConfigured = false }) {
export default function Index({
domain,
isConfigured = false,
asides,
customTrayComponent,
customAppComponent,
}) {
const [roomName, setRoomName] = useState('');
const [fetchingToken, setFetchingToken] = useState(false);
const [token, setToken] = useState();
@ -66,6 +72,7 @@ export default function Index({ domain, isConfigured = false }) {
<NotConfigured />
) : (
<Intro
title={process.env.PROJECT_TITLE}
room={roomName}
error={tokenError}
fetching={fetchingToken}
@ -91,13 +98,13 @@ export default function Index({ domain, isConfigured = false }) {
* Main call UI
*/
return (
<UIStateProvider>
<UIStateProvider asides={asides} customTrayComponent={customTrayComponent}>
<CallProvider domain={domain} room={roomName} token={token}>
<ParticipantsProvider>
<TracksProvider>
<MediaDeviceProvider>
<WaitingRoomProvider>
<App />
{customAppComponent || <App />}
</WaitingRoomProvider>
</MediaDeviceProvider>
</TracksProvider>
@ -110,6 +117,9 @@ export default function Index({ domain, isConfigured = false }) {
Index.propTypes = {
isConfigured: PropTypes.bool.isRequired,
domain: PropTypes.string,
asides: PropTypes.arrayOf(PropTypes.func),
customTrayComponent: PropTypes.node,
customAppComponent: PropTypes.node,
};
export async function getStaticProps() {
@ -119,6 +129,9 @@ export async function getStaticProps() {
// Pass through domain as prop
return {
props: { domain: process.env.DAILY_DOMAIN || null, isConfigured },
props: {
domain: process.env.DAILY_DOMAIN || null,
isConfigured,
},
};
}

Binary file not shown.

View File

@ -22,6 +22,8 @@ export const Aside = ({ onClose, children }) => (
.call-aside {
background: white;
position: relative;
flex-shrink: 0;
flex-grow: 0;
width: ${ASIDE_WIDTH}px;
height: 100vh;
box-sizing: border-box;
@ -32,6 +34,9 @@ export const Aside = ({ onClose, children }) => (
.call-aside .inner {
overflow-x: hidden;
overflow-y: scroll;
height: 100%;
display: flex;
flex-flow: column wrap;
}
.call-aside .close {

View File

@ -10,6 +10,8 @@ import { useParticipants } from '../../contexts/ParticipantsProvider';
import { useUIState } from '../../contexts/UIStateProvider';
import { Button } from '../Button';
export const PEOPLE_ASIDE = 'people';
const PersonRow = ({ participant, isOwner = false }) => (
<div className="person-row">
<div className="name">
@ -93,15 +95,15 @@ PersonRow.propTypes = {
export const PeopleAside = () => {
const { callObject } = useCallState();
const { showPeopleAside, setShowPeopleAside } = useUIState();
const { showAside, setShowAside } = useUIState();
const { allParticipants, isOwner } = useParticipants();
if (!showPeopleAside) {
if (!showAside || showAside !== PEOPLE_ASIDE) {
return null;
}
return (
<Aside onClose={() => setShowPeopleAside(false)}>
<Aside onClose={() => setShowAside(false)}>
{isOwner && (
<div className="owner-actions">
<Button

View File

@ -242,6 +242,19 @@ export const Button = forwardRef(
background: ${hexa(theme.blue.light, 1)};
}
.button.transparent {
background: transparent;
color: var(--primary-default);
border: 0px;
}
.button.transparent:hover,
.button.transparent:focus,
.button.transparent:active {
border: 0px;
box-shadow: none;
color: var(--primary-dark);
}
.button.blur {
background: ${hexa(theme.blue.default, 0.35)};
backdrop-filter: blur(10px);

View File

@ -0,0 +1,14 @@
import React from 'react';
import Head from 'next/head';
export const GlobalHead = () => (
<Head>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</Head>
);
export default GlobalHead;

View File

@ -0,0 +1,2 @@
export { GlobalHead as default } from './GlobalHead';
export { GlobalHead } from './GlobalHead';

View File

@ -120,6 +120,12 @@ const InputContainer = ({ children, prefix, className }) => (
color: var(--text-mid);
opacity: 1;
}
.transparent :global(input) {
background: transparent;
border: 0px;
box-shadow: none;
}
`}</style>
</div>
);

View File

@ -0,0 +1,64 @@
import React from 'react';
import { PEOPLE_ASIDE } from '@dailyjs/shared/components/Aside/PeopleAside';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { ReactComponent as IconCameraOff } from '@dailyjs/shared/icons/camera-off-md.svg';
import { ReactComponent as IconCameraOn } from '@dailyjs/shared/icons/camera-on-md.svg';
import { ReactComponent as IconLeave } from '@dailyjs/shared/icons/leave-md.svg';
import { ReactComponent as IconMicOff } from '@dailyjs/shared/icons/mic-off-md.svg';
import { ReactComponent as IconMicOn } from '@dailyjs/shared/icons/mic-on-md.svg';
import { ReactComponent as IconPeople } from '@dailyjs/shared/icons/people-md.svg';
import { ReactComponent as IconSettings } from '@dailyjs/shared/icons/settings-md.svg';
import { Tray, TrayButton } from './Tray';
export const BasicTray = () => {
const { callObject, leave } = useCallState();
const { customTrayComponent, setShowDeviceModal, toggleAside } = useUIState();
const { isCamMuted, isMicMuted } = useMediaDevices();
const toggleCamera = (newState) => {
if (!callObject) return false;
return callObject.setLocalVideo(newState);
};
const toggleMic = (newState) => {
if (!callObject) return false;
return callObject.setLocalAudio(newState);
};
return (
<Tray>
<TrayButton
label="Camera"
onClick={() => toggleCamera(isCamMuted)}
orange={isCamMuted}
>
{isCamMuted ? <IconCameraOff /> : <IconCameraOn />}
</TrayButton>
<TrayButton
label="Mic"
onClick={() => toggleMic(isMicMuted)}
orange={isMicMuted}
>
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
</TrayButton>
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
<IconSettings />
</TrayButton>
<TrayButton label="People" onClick={() => toggleAside(PEOPLE_ASIDE)}>
<IconPeople />
</TrayButton>
{customTrayComponent}
<span className="divider" />
<TrayButton label="Leave" onClick={() => leave()} orange>
<IconLeave />
</TrayButton>
</Tray>
);
};
export default BasicTray;

View File

@ -0,0 +1,89 @@
import React from 'react';
import Button from '@dailyjs/shared/components/Button';
import classNames from 'classnames';
import PropTypes from 'prop-types';
export const TrayButton = ({
children,
label,
onClick,
bubble = false,
orange = false,
}) => {
const cx = classNames('tray-button', { orange, bubble });
return (
<div className={cx}>
<Button onClick={() => onClick()} variant="dark" size="large-square">
{children}
</Button>
<span>{label}</span>
<style jsx>{`
.tray-button {
text-align: center;
user-select: none;
position: relative;
}
.tray-button.orange :global(.button) {
color: var(--secondary-dark);
}
.tray-button.bubble::after {
position: absolute;
content: '';
top: 10px;
right: 10px;
width: 9px;
height: 9px;
background: var(--green-default);
border-radius: 50%;
z-index: 99;
}
span {
color: white;
font-weight: var(--weight-medium);
font-size: 12px;
}
`}</style>
</div>
);
};
TrayButton.propTypes = {
children: PropTypes.node,
onClick: PropTypes.func,
orange: PropTypes.bool,
bubble: PropTypes.bool,
label: PropTypes.string.isRequired,
};
export const Tray = ({ children }) => (
<footer>
{children}
<style jsx>{`
footer {
flex: 0 0 auto;
padding: var(--spacing-xs);
box-sizing: border-box;
width: 100%;
display: flex;
justify-content: center;
gap: var(--spacing-xxs);
}
footer :global(.divider) {
width: 1px;
height: 52px;
background: rgba(255, 255, 255, 0.1);
}
`}</style>
</footer>
);
Tray.propTypes = {
children: PropTypes.node,
};
export default Tray;

View File

@ -0,0 +1,3 @@
export { Tray as default } from './Tray';
export { Tray, TrayButton } from './Tray';
export { BasicTray } from './BasicTray';

View File

@ -1,19 +1,26 @@
import React, { createContext, useContext, useState } from 'react';
import React, { useCallback, createContext, useContext, useState } from 'react';
import PropTypes from 'prop-types';
export const UIStateContext = createContext();
export const UIStateProvider = ({ children }) => {
export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
const [showDeviceModal, setShowDeviceModal] = useState(false);
const [showPeopleAside, setShowPeopleAside] = useState(false);
const [showAside, setShowAside] = useState();
const toggleAside = useCallback((newAside) => {
setShowAside((p) => (p === newAside ? null : newAside));
}, []);
return (
<UIStateContext.Provider
value={{
asides,
customTrayComponent,
showDeviceModal,
setShowDeviceModal,
showPeopleAside,
setShowPeopleAside,
toggleAside,
showAside,
setShowAside,
}}
>
{children}
@ -23,6 +30,8 @@ export const UIStateProvider = ({ children }) => {
UIStateProvider.propTypes = {
children: PropTypes.node,
asides: PropTypes.arrayOf(PropTypes.func),
customTrayComponent: PropTypes.node,
};
export const useUIState = () => useContext(UIStateContext);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>comments</title><g stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="none" stroke="currentColor"><path d="M23,5V16a2,2,0,0,1-2,2H19v4l-6-4H12" stroke="currentColor"></path><path d="M17,2H3A2,2,0,0,0,1,4v8a2,2,0,0,0,2,2H5v5l7-5h5a2,2,0,0,0,2-2V4A2,2,0,0,0,17,2Z"></path></g></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@ -6,8 +6,9 @@
"dependencies": {
"@daily-co/daily-js": "^0.12.0",
"classnames": "^2.3.1",
"no-scroll": "^2.1.1",
"debounce": "^1.2.1",
"nanoid": "^3.1.23",
"no-scroll": "^2.1.1",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": ["inline-react-svg"]
}

View File

@ -0,0 +1,41 @@
# Text Chat
![Text Chat](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-text-chat.vercel.app)**
---
## What does this demo do?
- Use [sendAppMessage](https://docs.daily.co/reference#%EF%B8%8F-sendappmessage) to send messages
- Listen for incoming messages using the call object `app-message` event
- Extend the basic call demo with a chat provider and aside
- Show a notification bubble on chat tray button when a new message is received
- Demonstrate how to play a sound whenever a message is received
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/text-chat dev
```
## How does this example work?
In this example we extend the [basic call demo](../basic-call) with the ability to send chat messages.
We pass a custom tray object, a custom app object (wrapping the original in a new `ChatProvider`) as well as add our `ChatAside` panel. We also symlink both the `public` and `pages/api` folders from the basic call.
In a real world use case you would likely want to implement serverside logic so that participants joining a call can retrieve previously sent messages. This round trip could be done inside of the Chat context.
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,13 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import { ChatProvider } from '../../contexts/ChatProvider';
// Extend our basic call app component with the chat context
export const AppWithChat = () => (
<ChatProvider>
<App />
</ChatProvider>
);
export default AppWithChat;

View File

@ -0,0 +1 @@
export { AppWithChat as default } from './App';

View File

@ -0,0 +1,132 @@
import React, { useEffect, useRef, useState } from 'react';
import Aside from '@dailyjs/shared/components/Aside';
import { Button } from '@dailyjs/shared/components/Button';
import { TextInput } from '@dailyjs/shared/components/Input';
import { useUIState } from '@dailyjs/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 [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]);
if (!showAside || showAside !== CHAT_ASIDE) {
return null;
}
return (
<Aside onClose={() => setShowAside(false)}>
<div className="messages-container" ref={chatWindowRef}>
{chatHistory.map((chatItem) => (
<div
className={chatItem.isLocal ? '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-default);
}
.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

@ -0,0 +1 @@
export { ChatAside as default } from './ChatAside';

View File

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

View File

@ -0,0 +1 @@
export { Tray as default } from './Tray';

View File

@ -0,0 +1,90 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { nanoid } from 'nanoid';
import PropTypes from 'prop-types';
export const ChatContext = createContext();
export const ChatProvider = ({ children }) => {
const { callObject } = useCallState();
const [chatHistory, setChatHistory] = useState([]);
const [hasNewMessages, setHasNewMessages] = useState(false);
const handleNewMessage = useCallback(
(e) => {
const participants = callObject.participants();
const sender = participants[e.fromId].user_name
? participants[e.fromId].user_name
: 'Guest';
setChatHistory((oldState) => [
...oldState,
{ sender, message: e.data.message, id: nanoid() },
]);
setHasNewMessages(true);
},
[callObject]
);
const sendMessage = useCallback(
(message) => {
if (!callObject) {
return false;
}
console.log('💬 Sending app message');
callObject.sendAppMessage({ message }, '*');
// Get the sender (local participant) name
const sender = callObject.participants().local.user_name
? callObject.participants().local.user_name
: 'Guest';
// Update local chat history
return setChatHistory((oldState) => [
...oldState,
{ sender, message, id: nanoid(), isLocal: true },
]);
},
[callObject]
);
useEffect(() => {
if (!callObject) {
return false;
}
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,
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 '@dailyjs/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('message.mp3');
useEffect(() => {
load();
}, [load]);
return useMemo(() => debounce(() => play(), 5000, true), [play]);
};
export default useMessageSound;

BIN
dailyjs/text-chat/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,24 @@
{
"name": "@dailyjs/text-chat",
"description": "Basic Call + Chat Example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@dailyjs/shared": "*",
"@dailyjs/basic-call": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithChat from '../components/App';
import ChatAside from '../components/ChatAside';
import Tray from '../components/Tray';
App.asides = [ChatAside];
App.customAppComponent = <AppWithChat />;
App.customTrayComponent = <Tray />;
export default App;

1
dailyjs/text-chat/pages/api Symbolic link
View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,17 @@
import Index from '@dailyjs/basic-call/pages';
export async function getStaticProps() {
// Check that both domain and key env vars are set
const isConfigured =
!!process.env.DAILY_DOMAIN && !!process.env.DAILY_API_KEY;
// Pass through domain as prop
return {
props: {
domain: process.env.DAILY_DOMAIN || null,
isConfigured,
},
};
}
export default Index;

1
dailyjs/text-chat/public Symbolic link
View File

@ -0,0 +1 @@
../basic-call/public

2923
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,9 @@
"dailyjs/*",
"prebuilt-ui/*"
],
"engines": {
"node": ">=0.12"
},
"devDependencies": {
"babel-eslint": "^10.1.0",
"babel-plugin-inline-react-svg": "^2.0.1",

View File

@ -2302,6 +2302,11 @@ nanoid@^3.1.22:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==
nanoid@^3.1.23:
version "3.1.23"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
native-url@0.3.4:
version "0.3.4"
resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.3.4.tgz#29c943172aed86c63cee62c8c04db7f5756661f8"