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:
parent
c911f68552
commit
f6a3e30da2
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci --no-optional
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
- name: 'Build web app artifacts'
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,6 +17,7 @@
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.parcel-cache
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
27
README.md
27
README.md
@ -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)
|
||||
- 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
|
||||
|
||||
In the project directory, you can run:
|
||||
|
4
nodemon.json
Normal file
4
nodemon.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"watch": ["sdk/"],
|
||||
"ext": "ts"
|
||||
}
|
6292
package-lock.json
generated
6292
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -55,7 +55,10 @@
|
||||
"start:tracker": "bittorrent-tracker",
|
||||
"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\"",
|
||||
"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",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier 'src/**/*.js' --write",
|
||||
@ -101,6 +104,8 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^8.0.1",
|
||||
"mprocs": "^0.6.4",
|
||||
"nodemon": "^3.0.1",
|
||||
"parcel": "^2.10.0",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.7.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
|
176
sdk/sdk.ts
Normal file
176
sdk/sdk.ts
Normal 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)
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
@ -11,25 +11,65 @@ import localforage from 'localforage'
|
||||
import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
|
||||
import { StorageContext } from 'contexts/StorageContext'
|
||||
import { SettingsContext } from 'contexts/SettingsContext'
|
||||
import { routes } from 'config/routes'
|
||||
import { homepageUrl, routes } from 'config/routes'
|
||||
import { Home } from 'pages/Home'
|
||||
import { About } from 'pages/About'
|
||||
import { Disclaimer } from 'pages/Disclaimer'
|
||||
import { Settings } from 'pages/Settings'
|
||||
import { PublicRoom } from 'pages/PublicRoom'
|
||||
import { PrivateRoom } from 'pages/PrivateRoom'
|
||||
import { UserSettings } from 'models/settings'
|
||||
import { ColorMode, UserSettings } from 'models/settings'
|
||||
import { PersistedStorageKeys } from 'models/storage'
|
||||
import { QueryParamKeys } from 'models/shell'
|
||||
import { Shell } from 'components/Shell'
|
||||
import {
|
||||
isConfigMessageEvent,
|
||||
PostMessageEvent,
|
||||
PostMessageEventName,
|
||||
} from 'models/sdk'
|
||||
|
||||
export interface BootstrapProps {
|
||||
persistedStorage?: typeof localforage
|
||||
getUuid?: typeof uuid
|
||||
}
|
||||
|
||||
const homepageUrl = new URL(
|
||||
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
|
||||
)
|
||||
const configListenerTimeout = 3000
|
||||
|
||||
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({
|
||||
persistedStorage: persistedStorageProp = localforage.createInstance({
|
||||
@ -38,13 +78,18 @@ function Bootstrap({
|
||||
}),
|
||||
getUuid = uuid,
|
||||
}: BootstrapProps) {
|
||||
const queryParams = useMemo(
|
||||
() => new URLSearchParams(window.location.search),
|
||||
[]
|
||||
)
|
||||
|
||||
const [persistedStorage] = useState(persistedStorageProp)
|
||||
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
|
||||
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>({
|
||||
userId: getUuid(),
|
||||
customUsername: '',
|
||||
colorMode: 'dark',
|
||||
colorMode: ColorMode.DARK,
|
||||
playSoundOnNewMessage: true,
|
||||
showNotificationOnNewMessage: true,
|
||||
showActiveTypingStatus: true,
|
||||
@ -55,6 +100,20 @@ function Bootstrap({
|
||||
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(() => {
|
||||
serviceWorkerRegistration.register({ onUpdate: handleServiceWorkerUpdate })
|
||||
}, [])
|
||||
@ -68,18 +127,70 @@ function Bootstrap({
|
||||
PersistedStorageKeys.USER_SETTINGS
|
||||
)
|
||||
|
||||
if (persistedUserSettings) {
|
||||
setUserSettings({ ...userSettings, ...persistedUserSettings })
|
||||
} else {
|
||||
await persistedStorageProp.setItem(
|
||||
PersistedStorageKeys.USER_SETTINGS,
|
||||
userSettings
|
||||
)
|
||||
const computeUserSettings = async (): Promise<UserSettings> => {
|
||||
if (queryParams.has(QueryParamKeys.GET_SDK_CONFIG)) {
|
||||
try {
|
||||
const configFromSdk = await getConfigFromSdk()
|
||||
|
||||
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)
|
||||
})()
|
||||
}, [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 = {
|
||||
updateUserSettings: async (changedSettings: Partial<UserSettings>) => {
|
||||
@ -88,10 +199,7 @@ function Bootstrap({
|
||||
...changedSettings,
|
||||
}
|
||||
|
||||
await persistedStorageProp.setItem(
|
||||
PersistedStorageKeys.USER_SETTINGS,
|
||||
newSettings
|
||||
)
|
||||
await persistUserSettings(newSettings)
|
||||
|
||||
setUserSettings(newSettings)
|
||||
},
|
||||
|
@ -24,6 +24,7 @@ import GitInfo from 'react-git-info/macro'
|
||||
|
||||
import { routes } from 'config/routes'
|
||||
import { SettingsContext } from 'contexts/SettingsContext'
|
||||
import { ColorMode } from 'models/settings'
|
||||
|
||||
const { commit } = GitInfo()
|
||||
|
||||
@ -40,7 +41,8 @@ export const Drawer = ({ isDrawerOpen, onDrawerClose, theme }: DrawerProps) => {
|
||||
const colorMode = settingsContext.getUserSettings().colorMode
|
||||
|
||||
const handleColorModeToggleClick = () => {
|
||||
const newMode = colorMode === 'light' ? 'dark' : 'light'
|
||||
const newMode =
|
||||
colorMode === ColorMode.LIGHT ? ColorMode.DARK : ColorMode.LIGHT
|
||||
settingsContext.updateUserSettings({ colorMode: newMode })
|
||||
}
|
||||
|
||||
|
6
src/config/iframeFeatureAllowList.ts
Normal file
6
src/config/iframeFeatureAllowList.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const iframeFeatureAllowList = [
|
||||
'camera',
|
||||
'microphone',
|
||||
'display-capture',
|
||||
'fullscreen',
|
||||
]
|
@ -7,3 +7,7 @@ export enum routes {
|
||||
ROOT = '/',
|
||||
SETTINGS = '/settings',
|
||||
}
|
||||
|
||||
export const homepageUrl = new URL(
|
||||
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import { UserSettings } from 'models/settings'
|
||||
import { ColorMode, UserSettings } from 'models/settings'
|
||||
|
||||
export interface SettingsContextProps {
|
||||
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
|
||||
@ -12,7 +12,7 @@ export const SettingsContext = createContext<SettingsContextProps>({
|
||||
getUserSettings: () => ({
|
||||
userId: '',
|
||||
customUsername: '',
|
||||
colorMode: 'dark',
|
||||
colorMode: ColorMode.DARK,
|
||||
playSoundOnNewMessage: true,
|
||||
showNotificationOnNewMessage: true,
|
||||
showActiveTypingStatus: true,
|
||||
|
57
src/models/sdk.ts
Normal file
57
src/models/sdk.ts
Normal 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
|
||||
}
|
@ -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 {
|
||||
colorMode: 'dark' | 'light'
|
||||
colorMode: ColorMode
|
||||
userId: string
|
||||
customUsername: string
|
||||
playSoundOnNewMessage: boolean
|
||||
|
@ -3,5 +3,7 @@ import { AlertProps } from '@mui/material/Alert'
|
||||
export type AlertOptions = Pick<AlertProps, 'severity'>
|
||||
|
||||
export enum QueryParamKeys {
|
||||
GET_SDK_CONFIG = 'getSdkConfig',
|
||||
IS_EMBEDDED = 'embed',
|
||||
PARENT_DOMAIN = 'parentDomain',
|
||||
}
|
||||
|
@ -1,62 +1,136 @@
|
||||
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
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 { 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 Link from '@mui/material/Link'
|
||||
import { CopyableBlock } from 'components/CopyableBlock/CopyableBlock'
|
||||
|
||||
import { iframeFeatureAllowList } from 'config/iframeFeatureAllowList'
|
||||
import { homepageUrl } from 'config/routes'
|
||||
import { ChatEmbedAttributes } from 'models/sdk'
|
||||
|
||||
interface EmbedCodeDialogProps {
|
||||
showEmbedCode: boolean
|
||||
handleEmbedCodeWindowClose: () => void
|
||||
embedUrl: URL
|
||||
roomName: string
|
||||
}
|
||||
|
||||
const iframeFeatureAllowList = [
|
||||
'camera',
|
||||
'microphone',
|
||||
'display-capture',
|
||||
'fullscreen',
|
||||
]
|
||||
|
||||
export const EmbedCodeDialog = ({
|
||||
showEmbedCode,
|
||||
handleEmbedCodeWindowClose,
|
||||
embedUrl,
|
||||
}: EmbedCodeDialogProps) => (
|
||||
<Dialog open={showEmbedCode} onClose={handleEmbedCodeWindowClose}>
|
||||
<DialogTitle>Room embed code</DialogTitle>
|
||||
<DialogContent>
|
||||
<CopyableBlock>
|
||||
<SyntaxHighlighter
|
||||
language="html"
|
||||
style={materialDark}
|
||||
PreTag="div"
|
||||
lineProps={{
|
||||
style: {
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
roomName,
|
||||
}: EmbedCodeDialogProps) => {
|
||||
const iframeSrc = new URL(`${window.location.origin}/public/${roomName}`)
|
||||
iframeSrc.search = new URLSearchParams({ embed: '1' }).toString()
|
||||
|
||||
const needsRootUrlAttribute = window.location.origin !== homepageUrl.origin
|
||||
|
||||
const chatRoomAttributes = {
|
||||
width: '800',
|
||||
height: '800',
|
||||
[ChatEmbedAttributes.ROOM_NAME]: roomName,
|
||||
...(needsRootUrlAttribute && {
|
||||
[ChatEmbedAttributes.ROOT_URL]: `${window.location.origin}/`,
|
||||
}),
|
||||
}
|
||||
|
||||
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(
|
||||
';'
|
||||
)}" width="800" height="800" />`}
|
||||
</SyntaxHighlighter>
|
||||
</CopyableBlock>
|
||||
<DialogContentText
|
||||
sx={{
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Copy and paste this HTML snippet into your project.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleEmbedCodeWindowClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
Copy and paste this <code>iframe</code> HTML snippet into your
|
||||
project:
|
||||
</DialogContentText>
|
||||
<CopyableBlock>
|
||||
<SyntaxHighlighter
|
||||
language="html"
|
||||
style={materialDark}
|
||||
PreTag="div"
|
||||
lineProps={{
|
||||
style: {
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
}}
|
||||
wrapLines={true}
|
||||
>
|
||||
{`<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>
|
||||
)
|
||||
}
|
||||
|
@ -61,9 +61,6 @@ export function Home({ userId }: HomeProps) {
|
||||
|
||||
const isRoomNameValid = roomName.length > 0
|
||||
|
||||
const embedUrl = new URL(`${window.location.origin}/public/${roomName}`)
|
||||
embedUrl.search = new URLSearchParams({ embed: '1' }).toString()
|
||||
|
||||
return (
|
||||
<Box className="Home">
|
||||
<main className="mt-6 px-4 max-w-3xl text-center mx-auto">
|
||||
@ -194,7 +191,7 @@ export function Home({ userId }: HomeProps) {
|
||||
<EmbedCodeDialog
|
||||
showEmbedCode={showEmbedCode}
|
||||
handleEmbedCodeWindowClose={handleEmbedCodeWindowClose}
|
||||
embedUrl={embedUrl}
|
||||
roomName={roomName}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { SettingsContextProps } from 'contexts/SettingsContext'
|
||||
import { UserSettings } from 'models/settings'
|
||||
import { ColorMode, UserSettings } from 'models/settings'
|
||||
|
||||
export const userSettingsContextStubFactory = (
|
||||
userSettingsOverrides: Partial<UserSettings> = {}
|
||||
@ -9,7 +9,7 @@ export const userSettingsContextStubFactory = (
|
||||
getUserSettings: () => ({
|
||||
userId: '',
|
||||
customUsername: '',
|
||||
colorMode: 'dark',
|
||||
colorMode: ColorMode.DARK,
|
||||
playSoundOnNewMessage: true,
|
||||
showNotificationOnNewMessage: true,
|
||||
showActiveTypingStatus: true,
|
||||
|
Loading…
Reference in New Issue
Block a user