forked from Shiloh/remnantchat
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
|
- 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
1
.gitignore
vendored
@ -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*
|
||||||
|
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)
|
- 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
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: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
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 {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
Routes,
|
Routes,
|
||||||
@ -11,26 +11,66 @@ 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({
|
||||||
name: 'chitchatter',
|
name: 'chitchatter',
|
||||||
@ -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)
|
||||||
},
|
},
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = '/',
|
ROOT = '/',
|
||||||
SETTINGS = '/settings',
|
SETTINGS = '/settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const homepageUrl = new URL(
|
||||||
|
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
|
||||||
|
)
|
||||||
|
@ -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
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 {
|
export interface UserSettings {
|
||||||
colorMode: 'dark' | 'light'
|
colorMode: ColorMode
|
||||||
userId: string
|
userId: string
|
||||||
customUsername: string
|
customUsername: string
|
||||||
playSoundOnNewMessage: boolean
|
playSoundOnNewMessage: boolean
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
@ -1,34 +1,66 @@
|
|||||||
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) => {
|
||||||
|
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}>
|
<Dialog open={showEmbedCode} onClose={handleEmbedCodeWindowClose}>
|
||||||
<DialogTitle>Room embed code</DialogTitle>
|
<DialogTitle>Basic Room Embed Code</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogContentText
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy and paste this <code>iframe</code> HTML snippet into your
|
||||||
|
project:
|
||||||
|
</DialogContentText>
|
||||||
<CopyableBlock>
|
<CopyableBlock>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="html"
|
language="html"
|
||||||
@ -42,21 +74,63 @@ export const EmbedCodeDialog = ({
|
|||||||
}}
|
}}
|
||||||
wrapLines={true}
|
wrapLines={true}
|
||||||
>
|
>
|
||||||
{`<iframe src="${embedUrl}" allow="${iframeFeatureAllowList.join(
|
{`<iframe src="${iframeSrc}" allow="${iframeFeatureAllowList.join(
|
||||||
';'
|
';'
|
||||||
)}" width="800" height="800" />`}
|
)}" width="800" height="800" />`}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</CopyableBlock>
|
</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
|
<DialogContentText
|
||||||
sx={{
|
sx={{
|
||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy and paste this HTML snippet into your project.
|
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>
|
</DialogContentText>
|
||||||
|
<CopyableBlock>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="html"
|
||||||
|
style={materialDark}
|
||||||
|
PreTag="div"
|
||||||
|
lineProps={{
|
||||||
|
style: {
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
wrapLines={true}
|
||||||
|
>
|
||||||
|
{sdkEmbedCode}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</CopyableBlock>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleEmbedCodeWindowClose}>Close</Button>
|
<Button onClick={handleEmbedCodeWindowClose}>Close</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user