feat(sdk): Implement Chitchatter SDK (#183)

* feat(sdk): render iframe in chat-room component
* fix(ci): install optional dependencies
* feat(sdk): allow subset of attributes
* feat(sdk): accept root-domain attribute
* feat(sdk): accept custom room name or use sane default
* feat(sdk): set allowed features
* feat(sdk): add sdk instructions to embed code dialog
* fix(sdk): use dynamic rootUrl
* fix(sdk): use static defaultRoot
* feat(sdk): send config from SDK to chat
* fix(sdk): expire poller
* fix(sdk): pass parent domain to iframe via query param
* refactor(sdk): type message event data
* feat(sdk): send user id to chat frame
* feat(sdk): handle some attribute updates
* chore(package): add build:sdk:watch script
* refactor(sdk): move more code to updateIframeAttributes
* feat(sdk): support changing rooms
* feat(sdk): support more user settings
* docs(sdk): add SDK section to README
* feat(sdk): render root-url in embed code if necessary
* refactor(sdk): use map for chat room attributes
* fix(sdk): unbind event listener when chat-room is disconnected
* fix(sdk): properly tear down receipt listener
* fix(sdk): send config when frame reloads
* feat(sdk): listen for config updates
* feat(sdk): request config from sdk instead of sending it repeatedly
* refactor(sdk): use type guard for config message
* fix(sdk): use settings from SDK when there is no preexisting persisted data
* fix(sdk): observe all iframe attributes
* refactor(sdk): simplify bootup logic
* feat(sdk): improve embed code display
This commit is contained in:
Jeremy Kahn 2023-10-28 11:42:58 -05:00 committed by GitHub
parent c911f68552
commit f6a3e30da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 6782 additions and 130 deletions

View File

@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 18 node-version: 18
- run: npm ci --no-optional - run: npm ci
- run: npm test - run: npm test
- name: 'Build web app artifacts' - name: 'Build web app artifacts'

1
.gitignore vendored
View File

@ -17,6 +17,7 @@
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.parcel-cache
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

@ -124,6 +124,33 @@ See the full ticket backlog [here](https://github.com/users/jeremyckahn/projects
- Mirror: https://chitchatter.vercel.app/ (note that peers cannot connect across domains) - Mirror: https://chitchatter.vercel.app/ (note that peers cannot connect across domains)
- Staging: https://chitchatter-git-develop-jeremyckahn.vercel.app/ - Staging: https://chitchatter-git-develop-jeremyckahn.vercel.app/
## SDK
You can use the official Chitchatter SDK to embed the app as a [Web Component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) called `<chat-room />`.
```html
<script src="https://chitchatter.im/sdk.js"></script>
<chat-room />
```
The `<chat-room />` component supports the following optional attributes:
- `room`: The name of the Chitchatter room the user should join. The default value is the URL of the embedding page.
- `user-name`: The friendly name of the user (which they can change).
- `user-id`: The static ID of the user. The default value is a random UUID.
- `root-url`: The URL of the Chitchatter instance to use. The default value is `https://chitchatter.im/`.
- `color-mode`: `light` or `dark`. The default value is `dark`.
- `play-message-sound`: Whether or not to play a sound when a user receives a message while the window is not in focus. The default value is `false`.
As well as the following [standard `<iframe />` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attributes):
- `height`
- `width`
- `style`
- `referrerpolicy`
- `sandbox`
## Available Scripts ## Available Scripts
In the project directory, you can run: In the project directory, you can run:

4
nodemon.json Normal file
View File

@ -0,0 +1,4 @@
{
"watch": ["sdk/"],
"ext": "ts"
}

6292
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,10 @@
"start:tracker": "bittorrent-tracker", "start:tracker": "bittorrent-tracker",
"start:streamsaver": "serve -p 3015 node_modules/streamsaver", "start:streamsaver": "serve -p 3015 node_modules/streamsaver",
"dev": "mprocs \"npx cross-env REACT_APP_TRACKER_URL=\"ws://localhost:8000\" REACT_APP_STREAMSAVER_URL=\"http://localhost:3015/mitm.html\" npm run start\" \"npm run start:tracker\" \"npm run start:streamsaver\"", "dev": "mprocs \"npx cross-env REACT_APP_TRACKER_URL=\"ws://localhost:8000\" REACT_APP_STREAMSAVER_URL=\"http://localhost:3015/mitm.html\" npm run start\" \"npm run start:tracker\" \"npm run start:streamsaver\"",
"build": "cross-env REACT_APP_HOMEPAGE=$(npm pkg get homepage) react-scripts build", "build": "npm run build:app && npm run build:sdk",
"build:app": "cross-env REACT_APP_HOMEPAGE=$(npm pkg get homepage) react-scripts build",
"build:sdk": "parcel build sdk/sdk.ts --dist-dir build --no-content-hash",
"build:sdk:watch": "nodemon --exec \"npm run build:sdk\"",
"test": "react-scripts test", "test": "react-scripts test",
"prepare": "husky install", "prepare": "husky install",
"prettier": "prettier 'src/**/*.js' --write", "prettier": "prettier 'src/**/*.js' --write",
@ -101,6 +104,8 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.1", "husky": "^8.0.1",
"mprocs": "^0.6.4", "mprocs": "^0.6.4",
"nodemon": "^3.0.1",
"parcel": "^2.10.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"pretty-quick": "^3.1.3", "pretty-quick": "^3.1.3",

176
sdk/sdk.ts Normal file
View File

@ -0,0 +1,176 @@
import {
ChatEmbedAttributes,
PostMessageEvent,
PostMessageEventName,
isPostMessageEvent,
} from '../src/models/sdk'
import { QueryParamKeys } from '../src/models/shell'
import { isColorMode, UserSettings } from '../src/models/settings'
import { iframeFeatureAllowList } from '../src/config/iframeFeatureAllowList'
export const defaultRoot = 'https://chitchatter.im/'
// NOTE: This is a subset of standard iframe attributes:
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attributes
const iframeAttributes = [
'height',
'referrerpolicy',
'sandbox',
'style',
'width',
]
const configRequestTimeout = 10_000
class ChatEmbed extends HTMLElement {
private configRequestExpirationTimer: NodeJS.Timer | null = null
private iframe = document.createElement('iframe')
static get observedAttributes() {
const chatAttributes = Object.values(ChatEmbedAttributes)
return [...chatAttributes, ...iframeAttributes]
}
get chatConfig() {
const chatConfig: Partial<UserSettings> = {}
if (this.hasAttribute(ChatEmbedAttributes.USER_ID)) {
chatConfig.userId = this.getAttribute(ChatEmbedAttributes.USER_ID) ?? ''
}
if (this.hasAttribute(ChatEmbedAttributes.USER_NAME)) {
chatConfig.customUsername = this.getAttribute(
ChatEmbedAttributes.USER_NAME
)!
}
chatConfig.playSoundOnNewMessage = Boolean(
this.hasAttribute(ChatEmbedAttributes.PLAY_MESSAGE_SOUND)
)
const colorMode = this.getAttribute(ChatEmbedAttributes.COLOR_MODE) ?? ''
if (isColorMode(colorMode)) {
chatConfig.colorMode = colorMode
}
return chatConfig
}
get rootUrl() {
return this.getAttribute(ChatEmbedAttributes.ROOT_URL) ?? defaultRoot
}
private sendConfigToChat = () => {
const { iframe, rootUrl } = this
const { origin: rootUrlOrigin } = new URL(rootUrl)
const postMessageEventData: PostMessageEvent['data'] = {
name: PostMessageEventName.CONFIG,
payload: this.chatConfig,
}
iframe.contentWindow?.postMessage(postMessageEventData, rootUrlOrigin)
}
private handleConfigRequestedMessage = (event: MessageEvent) => {
const { rootUrl } = this
const { origin: rootUrlOrigin } = new URL(rootUrl)
if (rootUrlOrigin !== event.origin) return
if (!isPostMessageEvent(event)) return
if (event.data.name !== PostMessageEventName.CONFIG_REQUESTED) return
this.sendConfigToChat()
this.stopListeningForConfigRequest()
}
private stopListeningForConfigRequest = () => {
window.removeEventListener('message', this.handleConfigRequestedMessage)
if (this.configRequestExpirationTimer !== null) {
clearInterval(this.configRequestExpirationTimer)
this.configRequestExpirationTimer = null
}
}
private async listenForConfigRequest() {
// NOTE: This cancels any pending config request listeners
this.stopListeningForConfigRequest()
window.addEventListener('message', this.handleConfigRequestedMessage)
this.configRequestExpirationTimer = setTimeout(() => {
console.error(`[chitchatter-sdk] configuration was not sent successfully`)
this.stopListeningForConfigRequest()
}, configRequestTimeout)
}
private updateIframeAttributes() {
const { iframe } = this
const roomName = encodeURIComponent(
this.getAttribute(ChatEmbedAttributes.ROOM_NAME) ?? window.location.href
)
const urlParams = new URLSearchParams({
[QueryParamKeys.IS_EMBEDDED]: '',
[QueryParamKeys.GET_SDK_CONFIG]: '',
[QueryParamKeys.PARENT_DOMAIN]: encodeURIComponent(
window.location.origin
),
})
const iframeSrc = new URL(this.rootUrl)
iframeSrc.pathname = `public/${roomName}`
iframeSrc.search = urlParams.toString()
const { href: src } = iframeSrc
// NOTE: Only update src if the value has changed to avoid reloading the
// iframe unnecessarily.
if (src !== iframe.getAttribute('src')) {
iframe.setAttribute('src', src)
}
for (let attributeName of iframeAttributes) {
const attributeValue = this.getAttribute(attributeName)
if (attributeValue !== null) {
iframe.setAttribute(attributeName, attributeValue)
}
}
}
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' })
const { iframe } = this
iframe.style.border = 'none'
iframe.setAttribute('allow', iframeFeatureAllowList.join(';'))
shadow.appendChild(iframe)
iframe.addEventListener('load', () => {
this.listenForConfigRequest()
})
}
disconnectedCallback() {
this.stopListeningForConfigRequest()
}
attributeChangedCallback(name: string) {
this.updateIframeAttributes()
const isChatEmbedAttribute = Object.values(ChatEmbedAttributes)
.map(String) // NOTE: Needed to avoid type warnings.
.includes(name)
if (isChatEmbedAttribute) {
this.sendConfigToChat()
}
}
}
window.customElements.define('chat-room', ChatEmbed)

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
BrowserRouter as Router, BrowserRouter as Router,
Routes, Routes,
@ -11,25 +11,65 @@ import localforage from 'localforage'
import * as serviceWorkerRegistration from 'serviceWorkerRegistration' import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
import { StorageContext } from 'contexts/StorageContext' import { StorageContext } from 'contexts/StorageContext'
import { SettingsContext } from 'contexts/SettingsContext' import { SettingsContext } from 'contexts/SettingsContext'
import { routes } from 'config/routes' import { homepageUrl, routes } from 'config/routes'
import { Home } from 'pages/Home' import { Home } from 'pages/Home'
import { About } from 'pages/About' import { About } from 'pages/About'
import { Disclaimer } from 'pages/Disclaimer' import { Disclaimer } from 'pages/Disclaimer'
import { Settings } from 'pages/Settings' import { Settings } from 'pages/Settings'
import { PublicRoom } from 'pages/PublicRoom' import { PublicRoom } from 'pages/PublicRoom'
import { PrivateRoom } from 'pages/PrivateRoom' import { PrivateRoom } from 'pages/PrivateRoom'
import { UserSettings } from 'models/settings' import { ColorMode, UserSettings } from 'models/settings'
import { PersistedStorageKeys } from 'models/storage' import { PersistedStorageKeys } from 'models/storage'
import { QueryParamKeys } from 'models/shell'
import { Shell } from 'components/Shell' import { Shell } from 'components/Shell'
import {
isConfigMessageEvent,
PostMessageEvent,
PostMessageEventName,
} from 'models/sdk'
export interface BootstrapProps { export interface BootstrapProps {
persistedStorage?: typeof localforage persistedStorage?: typeof localforage
getUuid?: typeof uuid getUuid?: typeof uuid
} }
const homepageUrl = new URL( const configListenerTimeout = 3000
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
) const getConfigFromSdk = () => {
const queryParams = new URLSearchParams(window.location.search)
const { origin: parentFrameOrigin } = new URL(
decodeURIComponent(queryParams.get(QueryParamKeys.PARENT_DOMAIN) ?? '')
)
return new Promise<Partial<UserSettings>>((resolve, reject) => {
let expireTimer: NodeJS.Timer
const expireListener = () => {
window.removeEventListener('message', handleMessage)
clearTimeout(expireTimer)
reject()
}
expireTimer = setTimeout(expireListener, configListenerTimeout)
const handleMessage = (event: MessageEvent) => {
if (!isConfigMessageEvent(event)) return
resolve(event.data.payload)
expireListener()
}
window.addEventListener('message', handleMessage)
const postMessageEvent: PostMessageEvent['data'] = {
name: PostMessageEventName.CONFIG_REQUESTED,
payload: {},
}
window.parent.postMessage(postMessageEvent, parentFrameOrigin)
})
}
function Bootstrap({ function Bootstrap({
persistedStorage: persistedStorageProp = localforage.createInstance({ persistedStorage: persistedStorageProp = localforage.createInstance({
@ -38,13 +78,18 @@ function Bootstrap({
}), }),
getUuid = uuid, getUuid = uuid,
}: BootstrapProps) { }: BootstrapProps) {
const queryParams = useMemo(
() => new URLSearchParams(window.location.search),
[]
)
const [persistedStorage] = useState(persistedStorageProp) const [persistedStorage] = useState(persistedStorageProp)
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false) const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
const [hasLoadedSettings, setHasLoadedSettings] = useState(false) const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
const [userSettings, setUserSettings] = useState<UserSettings>({ const [userSettings, setUserSettings] = useState<UserSettings>({
userId: getUuid(), userId: getUuid(),
customUsername: '', customUsername: '',
colorMode: 'dark', colorMode: ColorMode.DARK,
playSoundOnNewMessage: true, playSoundOnNewMessage: true,
showNotificationOnNewMessage: true, showNotificationOnNewMessage: true,
showActiveTypingStatus: true, showActiveTypingStatus: true,
@ -55,6 +100,20 @@ function Bootstrap({
setAppNeedsUpdate(true) setAppNeedsUpdate(true)
} }
const persistUserSettings = useCallback(
(newUserSettings: UserSettings) => {
if (queryParams.has(QueryParamKeys.IS_EMBEDDED)) {
return Promise.resolve(userSettings)
}
return persistedStorageProp.setItem(
PersistedStorageKeys.USER_SETTINGS,
newUserSettings
)
},
[persistedStorageProp, queryParams, userSettings]
)
useEffect(() => { useEffect(() => {
serviceWorkerRegistration.register({ onUpdate: handleServiceWorkerUpdate }) serviceWorkerRegistration.register({ onUpdate: handleServiceWorkerUpdate })
}, []) }, [])
@ -68,18 +127,70 @@ function Bootstrap({
PersistedStorageKeys.USER_SETTINGS PersistedStorageKeys.USER_SETTINGS
) )
if (persistedUserSettings) { const computeUserSettings = async (): Promise<UserSettings> => {
setUserSettings({ ...userSettings, ...persistedUserSettings }) if (queryParams.has(QueryParamKeys.GET_SDK_CONFIG)) {
} else { try {
await persistedStorageProp.setItem( const configFromSdk = await getConfigFromSdk()
PersistedStorageKeys.USER_SETTINGS,
userSettings return {
) ...userSettings,
...persistedUserSettings,
...configFromSdk,
}
} catch (e) {
console.error(
'Chitchatter configuration from parent frame could not be loaded'
)
}
}
return {
...userSettings,
...persistedUserSettings,
}
}
const computedUserSettings = await computeUserSettings()
setUserSettings(computedUserSettings)
if (persistedUserSettings === null) {
await persistUserSettings(computedUserSettings)
} }
setHasLoadedSettings(true) setHasLoadedSettings(true)
})() })()
}, [hasLoadedSettings, persistedStorageProp, userSettings, userId]) }, [
hasLoadedSettings,
persistedStorageProp,
userSettings,
userId,
queryParams,
persistUserSettings,
])
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search)
if (!queryParams.has(QueryParamKeys.IS_EMBEDDED)) return
const handleConfigMessage = (event: MessageEvent) => {
if (!hasLoadedSettings) return
if (!isConfigMessageEvent(event)) return
const overrideConfig: Partial<UserSettings> = event.data.payload
setUserSettings({
...userSettings,
...overrideConfig,
})
}
window.addEventListener('message', handleConfigMessage)
return () => {
window.removeEventListener('message', handleConfigMessage)
}
}, [hasLoadedSettings, userSettings])
const settingsContextValue = { const settingsContextValue = {
updateUserSettings: async (changedSettings: Partial<UserSettings>) => { updateUserSettings: async (changedSettings: Partial<UserSettings>) => {
@ -88,10 +199,7 @@ function Bootstrap({
...changedSettings, ...changedSettings,
} }
await persistedStorageProp.setItem( await persistUserSettings(newSettings)
PersistedStorageKeys.USER_SETTINGS,
newSettings
)
setUserSettings(newSettings) setUserSettings(newSettings)
}, },

View File

@ -24,6 +24,7 @@ import GitInfo from 'react-git-info/macro'
import { routes } from 'config/routes' import { routes } from 'config/routes'
import { SettingsContext } from 'contexts/SettingsContext' import { SettingsContext } from 'contexts/SettingsContext'
import { ColorMode } from 'models/settings'
const { commit } = GitInfo() const { commit } = GitInfo()
@ -40,7 +41,8 @@ export const Drawer = ({ isDrawerOpen, onDrawerClose, theme }: DrawerProps) => {
const colorMode = settingsContext.getUserSettings().colorMode const colorMode = settingsContext.getUserSettings().colorMode
const handleColorModeToggleClick = () => { const handleColorModeToggleClick = () => {
const newMode = colorMode === 'light' ? 'dark' : 'light' const newMode =
colorMode === ColorMode.LIGHT ? ColorMode.DARK : ColorMode.LIGHT
settingsContext.updateUserSettings({ colorMode: newMode }) settingsContext.updateUserSettings({ colorMode: newMode })
} }

View File

@ -0,0 +1,6 @@
export const iframeFeatureAllowList = [
'camera',
'microphone',
'display-capture',
'fullscreen',
]

View File

@ -7,3 +7,7 @@ export enum routes {
ROOT = '/', ROOT = '/',
SETTINGS = '/settings', SETTINGS = '/settings',
} }
export const homepageUrl = new URL(
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
)

View File

@ -1,6 +1,6 @@
import { createContext } from 'react' import { createContext } from 'react'
import { UserSettings } from 'models/settings' import { ColorMode, UserSettings } from 'models/settings'
export interface SettingsContextProps { export interface SettingsContextProps {
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void> updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
@ -12,7 +12,7 @@ export const SettingsContext = createContext<SettingsContextProps>({
getUserSettings: () => ({ getUserSettings: () => ({
userId: '', userId: '',
customUsername: '', customUsername: '',
colorMode: 'dark', colorMode: ColorMode.DARK,
playSoundOnNewMessage: true, playSoundOnNewMessage: true,
showNotificationOnNewMessage: true, showNotificationOnNewMessage: true,
showActiveTypingStatus: true, showActiveTypingStatus: true,

57
src/models/sdk.ts Normal file
View File

@ -0,0 +1,57 @@
import { UserSettings } from 'models/settings'
import { QueryParamKeys } from 'models/shell'
export enum PostMessageEventName {
CONFIG = 'config',
CONFIG_REQUESTED = 'configRequested',
}
export enum ChatEmbedAttributes {
COLOR_MODE = 'color-mode',
PLAY_MESSAGE_SOUND = 'play-message-sound',
ROOM_NAME = 'room',
ROOT_URL = 'root-url',
USER_ID = 'user-id',
USER_NAME = 'user-name',
}
export interface PostMessageEvent extends MessageEvent {
data: {
name: PostMessageEventName
payload: Record<string, any>
}
}
export interface ConfigMessageEvent extends PostMessageEvent {
data: {
name: PostMessageEventName.CONFIG
payload: Partial<UserSettings>
}
}
export const isPostMessageEvent = (
event: MessageEvent
): event is PostMessageEvent => {
const { data } = event
if (typeof data !== 'object' || data === null) return false
if (!('name' in data && typeof data.name === 'string')) return false
if (!('payload' in data && typeof data.payload === 'object')) return false
return true
}
export const isConfigMessageEvent = (
event: MessageEvent
): event is ConfigMessageEvent => {
const queryParams = new URLSearchParams(window.location.search)
const { origin: parentFrameOrigin } = new URL(
decodeURIComponent(queryParams.get(QueryParamKeys.PARENT_DOMAIN) ?? '')
)
if (event.origin !== parentFrameOrigin) return false
if (!isPostMessageEvent(event)) return false
if (event.data.name !== PostMessageEventName.CONFIG) return false
return true
}

View File

@ -1,5 +1,16 @@
export enum ColorMode {
DARK = 'dark',
LIGHT = 'light',
}
const ColorModeValueStrings = Object.values(ColorMode).map(String)
export const isColorMode = (color: string): color is ColorMode => {
return ColorModeValueStrings.includes(color)
}
export interface UserSettings { export interface UserSettings {
colorMode: 'dark' | 'light' colorMode: ColorMode
userId: string userId: string
customUsername: string customUsername: string
playSoundOnNewMessage: boolean playSoundOnNewMessage: boolean

View File

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

View File

@ -1,62 +1,136 @@
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter' import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism' import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText' import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link'
import { CopyableBlock } from 'components/CopyableBlock/CopyableBlock' import { CopyableBlock } from 'components/CopyableBlock/CopyableBlock'
import { iframeFeatureAllowList } from 'config/iframeFeatureAllowList'
import { homepageUrl } from 'config/routes'
import { ChatEmbedAttributes } from 'models/sdk'
interface EmbedCodeDialogProps { interface EmbedCodeDialogProps {
showEmbedCode: boolean showEmbedCode: boolean
handleEmbedCodeWindowClose: () => void handleEmbedCodeWindowClose: () => void
embedUrl: URL roomName: string
} }
const iframeFeatureAllowList = [
'camera',
'microphone',
'display-capture',
'fullscreen',
]
export const EmbedCodeDialog = ({ export const EmbedCodeDialog = ({
showEmbedCode, showEmbedCode,
handleEmbedCodeWindowClose, handleEmbedCodeWindowClose,
embedUrl, roomName,
}: EmbedCodeDialogProps) => ( }: EmbedCodeDialogProps) => {
<Dialog open={showEmbedCode} onClose={handleEmbedCodeWindowClose}> const iframeSrc = new URL(`${window.location.origin}/public/${roomName}`)
<DialogTitle>Room embed code</DialogTitle> iframeSrc.search = new URLSearchParams({ embed: '1' }).toString()
<DialogContent>
<CopyableBlock> const needsRootUrlAttribute = window.location.origin !== homepageUrl.origin
<SyntaxHighlighter
language="html" const chatRoomAttributes = {
style={materialDark} width: '800',
PreTag="div" height: '800',
lineProps={{ [ChatEmbedAttributes.ROOM_NAME]: roomName,
style: { ...(needsRootUrlAttribute && {
wordBreak: 'break-all', [ChatEmbedAttributes.ROOT_URL]: `${window.location.origin}/`,
whiteSpace: 'pre-wrap', }),
}, }
const attributesString = Object.entries(chatRoomAttributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
// NOTE: The script src is inaccurate in the local development environment.
const sdkEmbedCode = `<script src="${window.location.origin}/sdk.js"></script>
<chat-room ${attributesString} />`
return (
<Dialog open={showEmbedCode} onClose={handleEmbedCodeWindowClose}>
<DialogTitle>Basic Room Embed Code</DialogTitle>
<DialogContent>
<DialogContentText
sx={{
mb: 2,
}} }}
wrapLines={true}
> >
{`<iframe src="${embedUrl}" allow="${iframeFeatureAllowList.join( Copy and paste this <code>iframe</code> HTML snippet into your
';' project:
)}" width="800" height="800" />`} </DialogContentText>
</SyntaxHighlighter> <CopyableBlock>
</CopyableBlock> <SyntaxHighlighter
<DialogContentText language="html"
sx={{ style={materialDark}
mb: 2, PreTag="div"
}} lineProps={{
> style: {
Copy and paste this HTML snippet into your project. wordBreak: 'break-all',
</DialogContentText> whiteSpace: 'pre-wrap',
</DialogContent> },
<DialogActions> }}
<Button onClick={handleEmbedCodeWindowClose}>Close</Button> wrapLines={true}
</DialogActions> >
</Dialog> {`<iframe src="${iframeSrc}" allow="${iframeFeatureAllowList.join(
) ';'
)}" width="800" height="800" />`}
</SyntaxHighlighter>
</CopyableBlock>
<Divider sx={{ my: 2 }} />
<Typography
variant="h3"
sx={theme => ({
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
mb: 2,
})}
>
Advanced Usage
</Typography>
<DialogContentText
sx={{
mb: 2,
}}
>
Alternatively, you can use the{' '}
<Link
href="https://github.com/jeremyckahn/chitchatter#SDK"
target="_blank"
>
Chitchatter SDK
</Link>{' '}
to embed a chat room as a{' '}
<Link
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_components"
target="_blank"
>
Web Component
</Link>{' '}
with additional configuration options:
</DialogContentText>
<CopyableBlock>
<SyntaxHighlighter
language="html"
style={materialDark}
PreTag="div"
lineProps={{
style: {
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
},
}}
wrapLines={true}
>
{sdkEmbedCode}
</SyntaxHighlighter>
</CopyableBlock>
</DialogContent>
<DialogActions>
<Button onClick={handleEmbedCodeWindowClose}>Close</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -61,9 +61,6 @@ export function Home({ userId }: HomeProps) {
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">
@ -194,7 +191,7 @@ export function Home({ userId }: HomeProps) {
<EmbedCodeDialog <EmbedCodeDialog
showEmbedCode={showEmbedCode} showEmbedCode={showEmbedCode}
handleEmbedCodeWindowClose={handleEmbedCodeWindowClose} handleEmbedCodeWindowClose={handleEmbedCodeWindowClose}
embedUrl={embedUrl} roomName={roomName}
/> />
</Box> </Box>
) )

View File

@ -1,5 +1,5 @@
import { SettingsContextProps } from 'contexts/SettingsContext' import { SettingsContextProps } from 'contexts/SettingsContext'
import { UserSettings } from 'models/settings' import { ColorMode, UserSettings } from 'models/settings'
export const userSettingsContextStubFactory = ( export const userSettingsContextStubFactory = (
userSettingsOverrides: Partial<UserSettings> = {} userSettingsOverrides: Partial<UserSettings> = {}
@ -9,7 +9,7 @@ export const userSettingsContextStubFactory = (
getUserSettings: () => ({ getUserSettings: () => ({
userId: '', userId: '',
customUsername: '', customUsername: '',
colorMode: 'dark', colorMode: ColorMode.DARK,
playSoundOnNewMessage: true, playSoundOnNewMessage: true,
showNotificationOnNewMessage: true, showNotificationOnNewMessage: true,
showActiveTypingStatus: true, showActiveTypingStatus: true,