feat(embed): [closes #92] Embed support (#177)

* feat(embed): [#92] hide unnecessary UI when embedded
* feat(embed): [#92] provide embed code
This commit is contained in:
Jeremy Kahn 2023-10-08 14:19:46 -04:00 committed by GitHub
parent 1b43b4aa00
commit 7acf267558
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 169 additions and 54 deletions

View File

@ -14,6 +14,7 @@ Chitchatter is a free (as in both price and freedom) communication tool. Designe
- Message content is never persisted to disk on either the client or server - Message content is never persisted to disk on either the client or server
- Decentralized - Decentralized
- There is no API server. All that's required for Chitchatter to function is availability of GitHub for static assets, and public WebTorrent and STUN/TURN relay servers for establishing peer-to-peer communication. - There is no API server. All that's required for Chitchatter to function is availability of GitHub for static assets, and public WebTorrent and STUN/TURN relay servers for establishing peer-to-peer communication.
- Embeddable
- [Self-hostable](#self-hosting) - [Self-hostable](#self-hosting)
Chitchatter uses the [Create React App](https://github.com/facebook/create-react-app) toolchain. The secure networking and streaming magic would not be possible without [Trystero](https://github.com/dmotz/trystero). File transfer functionality is powered by [`secure-file-transfer`](https://github.com/jeremyckahn/secure-file-transfer). Chitchatter uses the [Create React App](https://github.com/facebook/create-react-app) toolchain. The secure networking and streaming magic would not be possible without [Trystero](https://github.com/dmotz/trystero). File transfer functionality is powered by [`secure-file-transfer`](https://github.com/jeremyckahn/secure-file-transfer).
@ -31,6 +32,7 @@ Open https://chitchatter.im/ and join a room to start chatting with anyone else
- File sharing: - File sharing:
- Unlimited file size transfers. - Unlimited file size transfers.
- Files are encrypted prior to sending and decrypted by the receiver (the key is the room name). - Files are encrypted prior to sending and decrypted by the receiver (the key is the room name).
- Embedding into other web apps via `iframe`.
- Markdown support via [`react-markdown`](https://github.com/remarkjs/react-markdown). - Markdown support via [`react-markdown`](https://github.com/remarkjs/react-markdown).
- Includes support for syntax highlighting of code. - Includes support for syntax highlighting of code.
- Conversation backfilling from peers when a new participant joins. - Conversation backfilling from peers when a new participant joins.

View File

@ -15,7 +15,7 @@ import { useWindowSize } from '@react-hook/window-size'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { SettingsContext } from 'contexts/SettingsContext' import { SettingsContext } from 'contexts/SettingsContext'
import { AlertOptions } from 'models/shell' import { AlertOptions, QueryParamKeys } from 'models/shell'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat' import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat'
import { ErrorBoundary } from 'components/ErrorBoundary' import { ErrorBoundary } from 'components/ErrorBoundary'
@ -41,8 +41,11 @@ export interface ShellProps extends PropsWithChildren {
appNeedsUpdate: boolean appNeedsUpdate: boolean
} }
const queryParams = new URLSearchParams(window.location.search)
export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const { getUserSettings, updateUserSettings } = useContext(SettingsContext) const { getUserSettings, updateUserSettings } = useContext(SettingsContext)
const isEmbedded = queryParams.get(QueryParamKeys.IS_EMBEDDED) !== null
const { colorMode } = getUserSettings() const { colorMode } = getUserSettings()
@ -65,7 +68,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [isRoomShareDialogOpen, setIsRoomShareDialogOpen] = useState(false) const [isRoomShareDialogOpen, setIsRoomShareDialogOpen] = useState(false)
const [alertSeverity, setAlertSeverity] = useState<AlertColor>('info') const [alertSeverity, setAlertSeverity] = useState<AlertColor>('info')
const [showAppBar, setShowAppBar] = useState(true) const [showAppBar, setShowAppBar] = useState(true)
const [showRoomControls, setShowRoomControls] = useState(true) const [showRoomControls, setShowRoomControls] = useState(!isEmbedded)
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [alertText, setAlertText] = useState('') const [alertText, setAlertText] = useState('')
@ -118,6 +121,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const shellContextValue = useMemo( const shellContextValue = useMemo(
() => ({ () => ({
isEmbedded,
tabHasFocus, tabHasFocus,
showRoomControls, showRoomControls,
setShowRoomControls, setShowRoomControls,
@ -150,6 +154,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
updatePeer, updatePeer,
}), }),
[ [
isEmbedded,
isPeerListOpen, isPeerListOpen,
setIsQRCodeDialogOpen, setIsQRCodeDialogOpen,
roomId, roomId,
@ -244,9 +249,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
} else { } else {
exitFullscreen() exitFullscreen()
setShowAppBar(true) setShowAppBar(true)
if (!isEmbedded) {
setShowRoomControls(true) setShowRoomControls(true)
} }
}, [isFullscreen, setShowRoomControls, setShowAppBar]) }
}, [isFullscreen, setShowRoomControls, setShowAppBar, isEmbedded])
useEffect(() => { useEffect(() => {
if (isFullscreen) setShowAppBar(showRoomControls) if (isFullscreen) setShowAppBar(showRoomControls)
@ -344,13 +352,15 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen} setIsFullscreen={setIsFullscreen}
/> />
{isEmbedded ? null : (
<Drawer <Drawer
isDrawerOpen={isDrawerOpen} isDrawerOpen={isDrawerOpen}
onDrawerClose={handleDrawerClose} onDrawerClose={handleDrawerClose}
theme={theme} theme={theme}
/> />
)}
<RouteContent <RouteContent
isDrawerOpen={isDrawerOpen} isDrawerOpen={isEmbedded ? true : isDrawerOpen}
isPeerListOpen={isPeerListOpen} isPeerListOpen={isPeerListOpen}
showAppBar={showAppBar} showAppBar={showAppBar}
> >

View File

@ -85,9 +85,10 @@ export const ShellAppBar = ({
isFullscreen, isFullscreen,
setIsFullscreen, setIsFullscreen,
}: ShellAppBarProps) => { }: ShellAppBarProps) => {
const { peerList } = useContext(ShellContext) const { peerList, isEmbedded } = useContext(ShellContext)
const handleQRCodeClick = () => setIsQRCodeDialogOpen(true) const handleQRCodeClick = () => setIsQRCodeDialogOpen(true)
const onClickFullscreen = () => setIsFullscreen(!isFullscreen) const onClickFullscreen = () => setIsFullscreen(!isFullscreen)
return ( return (
<> <>
<Slide appear={false} in={showAppBar} mountOnEnter unmountOnExit> <Slide appear={false} in={showAppBar} mountOnEnter unmountOnExit>
@ -104,6 +105,7 @@ export const ShellAppBar = ({
justifyContent: 'space-between', justifyContent: 'space-between',
}} }}
> >
{isEmbedded ? null : (
<IconButton <IconButton
size="large" size="large"
edge="start" edge="start"
@ -114,14 +116,17 @@ export const ShellAppBar = ({
> >
<Menu /> <Menu />
</IconButton> </IconButton>
)}
<Typography <Typography
variant="h6" variant="h6"
noWrap noWrap
component="div" component="div"
sx={{ marginRight: 'auto' }} sx={{ marginRight: 'auto' }}
> >
{title} {isEmbedded ? '' : title}
</Typography> </Typography>
{isEmbedded ? null : (
<>
<Tooltip title="Copy current URL"> <Tooltip title="Copy current URL">
<IconButton <IconButton
size="large" size="large"
@ -142,6 +147,8 @@ export const ShellAppBar = ({
<QrCode2 /> <QrCode2 />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</>
)}
<Tooltip title="Show Room Controls"> <Tooltip title="Show Room Controls">
<IconButton <IconButton
size="large" size="large"
@ -152,6 +159,7 @@ export const ShellAppBar = ({
<RoomPreferences /> <RoomPreferences />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{isEmbedded ? null : (
<Tooltip <Tooltip
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
> >
@ -165,6 +173,7 @@ export const ShellAppBar = ({
{isFullscreen ? <FullscreenExit /> : <Fullscreen />} {isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)}
<Tooltip title="Click to show peer list"> <Tooltip title="Click to show peer list">
<IconButton <IconButton
size="large" size="large"

View File

@ -7,6 +7,7 @@ import { ConnectionTestResults } from 'components/Shell/useConnectionTest'
import { TrackerConnection } from 'services/ConnectionTest/ConnectionTest' import { TrackerConnection } from 'services/ConnectionTest/ConnectionTest'
interface ShellContextProps { interface ShellContextProps {
isEmbedded: boolean
tabHasFocus: boolean tabHasFocus: boolean
showRoomControls: boolean showRoomControls: boolean
setShowRoomControls: Dispatch<SetStateAction<boolean>> setShowRoomControls: Dispatch<SetStateAction<boolean>>
@ -41,6 +42,7 @@ interface ShellContextProps {
} }
export const ShellContext = createContext<ShellContextProps>({ export const ShellContext = createContext<ShellContextProps>({
isEmbedded: false,
tabHasFocus: true, tabHasFocus: true,
showRoomControls: false, showRoomControls: false,
setShowRoomControls: () => {}, setShowRoomControls: () => {},

View File

@ -1,3 +1,7 @@
import { AlertProps } from '@mui/material/Alert' import { AlertProps } from '@mui/material/Alert'
export type AlertOptions = Pick<AlertProps, 'severity'> export type AlertOptions = Pick<AlertProps, 'severity'>
export enum QueryParamKeys {
IS_EMBEDDED = 'embed',
}

View File

@ -10,7 +10,28 @@ import IconButton from '@mui/material/IconButton'
import MuiLink from '@mui/material/Link' import MuiLink from '@mui/material/Link'
import GitHubIcon from '@mui/icons-material/GitHub' import GitHubIcon from '@mui/icons-material/GitHub'
import Cached from '@mui/icons-material/Cached' import Cached from '@mui/icons-material/Cached'
import Dialog from '@mui/material/Dialog'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
// These imports need to be ts-ignored to prevent spurious errors that look
// like this:
//
// Module 'react-markdown' cannot be imported using this construct. The
// specifier only resolves to an ES module, which cannot be imported
// synchronously. Use dynamic import instead. (tsserver 1471)
//
// @ts-ignore
import Markdown from 'react-markdown'
// @ts-ignore
import { CodeProps } from 'react-markdown/lib/ast-to-react'
// @ts-ignore
import remarkGfm from 'remark-gfm'
import { routes } from 'config/routes' import { routes } from 'config/routes'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
@ -24,6 +45,7 @@ interface HomeProps {
export function Home({ userId }: HomeProps) { export function Home({ userId }: HomeProps) {
const { setTitle } = useContext(ShellContext) const { setTitle } = useContext(ShellContext)
const [roomName, setRoomName] = useState(uuid()) const [roomName, setRoomName] = useState(uuid())
const [showEmbedCode, setShowEmbedCode] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@ -47,8 +69,19 @@ export function Home({ userId }: HomeProps) {
navigate(`/private/${roomName}`) navigate(`/private/${roomName}`)
} }
const handleGetEmbedCodeClick = () => {
setShowEmbedCode(true)
}
const handleEmbedCodeWindowClose = () => {
setShowEmbedCode(false)
}
const isRoomNameValid = roomName.length > 0 const isRoomNameValid = roomName.length > 0
const embedUrl = new URL(`${window.location.origin}/public/${roomName}`)
embedUrl.search = new URLSearchParams({ embed: '1' }).toString()
return ( return (
<Box className="Home"> <Box className="Home">
<main className="mt-6 px-4 max-w-3xl text-center mx-auto"> <main className="mt-6 px-4 max-w-3xl text-center mx-auto">
@ -110,6 +143,18 @@ export function Home({ userId }: HomeProps) {
> >
Join private room Join private room
</Button> </Button>
<Button
variant="contained"
color="secondary"
onClick={handleGetEmbedCodeClick}
sx={{
marginTop: 2,
marginLeft: 2,
}}
disabled={!isRoomNameValid}
>
Get embed code
</Button>
</Box> </Box>
</form> </form>
</main> </main>
@ -164,6 +209,49 @@ export function Home({ userId }: HomeProps) {
</MuiLink> </MuiLink>
. .
</Typography> </Typography>
<Dialog open={showEmbedCode} onClose={handleEmbedCodeWindowClose}>
<DialogTitle>Room embed code</DialogTitle>
<DialogContent>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
// https://github.com/remarkjs/react-markdown#use-custom-components-syntax-highlight
code({
node,
inline,
className,
children,
style,
...props
}: CodeProps) {
return (
<SyntaxHighlighter
children={String(children).replace(/\n$/, '')}
language="html"
style={materialDark}
PreTag="div"
lineProps={{
style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' },
}}
wrapLines={true}
{...props}
/>
)
},
}}
>
{`\`\`\`html
<iframe src="${embedUrl}" allow="camera;microphone;display-capture" width="800" height="800" />
\`\`\``}
</Markdown>
<DialogContentText sx={{ mb: 2 }}>
Copy and paste this HTML snippet into your project.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleEmbedCodeWindowClose}>Close</Button>
</DialogActions>
</Dialog>
</Box> </Box>
) )
} }