|
|
// composer.tsx
// Copyright (C) 2020 Dwayne Harris
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { FC, useState, useEffect, useRef } from 'react' import { useSelector, useDispatch } from 'react-redux'
import { getOrigin } from '../utils' import { useDeepCompareEffect, useTheme } from '../hooks' import { fetchInstallations, setSelectedInstallation, setHeight as setComposerHeight, setError as setComposerError, saveInstallationSettings, } from '../actions/composer' import { showNotification } from '../actions/notifications' import { createPost } from '../actions/posts' import { getInstallations, getSelectedInstallation, getError, getHeight as getComposerHeight } from '../selectors/composer' import { getColorScheme } from '../selectors/theme' import { AppThunkDispatch, NotificationType, Post } from '../types' import { IncomingMessageData, OutgoingMessageData } from '../types/communicator'
interface LimiterCollection { [key: string]: number }
interface Props { parent?: Post onPost?: () => void }
const Composer: FC<Props> = ({ parent, onPost }) => { const theme = useTheme() const colorScheme = useSelector(getColorScheme) const installations = useSelector(getInstallations) const installation = useSelector(getSelectedInstallation) const height = useSelector(getComposerHeight) const error = useSelector(getError) const dispatch = useDispatch<AppThunkDispatch>() const ref = useRef<HTMLIFrameElement>(null) const [limiters, setLimiters] = useState<LimiterCollection>({})
const composerUrl = installation ? installation.app.composerUrl : undefined const showComposer = !!composerUrl && !error
useEffect(() => { dispatch(fetchInstallations()) }, [])
useDeepCompareEffect(() => { if (!composerUrl) return if (!installation) return if (error) return
const listener = async (event: MessageEvent) => { const origin = getOrigin(composerUrl) if (event.origin !== origin) return
const postMessage = (message: OutgoingMessageData) => { if (ref.current && ref.current.contentWindow) { ref.current.contentWindow.postMessage(JSON.stringify(message), origin) } }
const withRateLimit = async (fn: (data: IncomingMessageData) => Promise<void>, data: IncomingMessageData, ms: number = 2000) => { const last = limiters[data.name] ?? 0 if ((Date.now() - last) > ms) { await fn(data) limiters[data.name] = Date.now() setLimiters(limiters) } else { postMessage({ name: data.name, error: 'Rate limited.', }) } }
let data: IncomingMessageData | undefined
try { data = JSON.parse(event.data) } catch (err) { dispatch(setComposerError('Invalid payload')) return }
if (!data) return
if (data.publicKey !== installation.app.publicKey) { const message = 'Invalid publicKey'
dispatch(setComposerError(message))
postMessage({ name: data.name, error: message, }) }
switch (data.name) { case 'init': postMessage({ name: data.name, content: { installationId: installation.id, settings: installation.settings, theme, colorScheme, parent: parent ? { text: parent.text, cover: parent.cover, attachments: parent.attachments, data: parent.data, created: parent.created, } : undefined, }, })
break case 'setHeight': const { height = 0 } = data.content dispatch(setComposerHeight(Math.max(Math.min(height, 400), 100)))
postMessage({ name: data.name, content: {}, })
break case 'saveSettings': withRateLimit(async ({ name, content }) => { try { await dispatch(saveInstallationSettings(installation.id, content.settings))
postMessage({ name, content: { settings: content.settings, }, }) } catch (error) { postMessage({ name, error, })
dispatch(showNotification(NotificationType.Error, `Error saving settings: ${error.message}`)) } }, data, 2000)
break case 'post': withRateLimit(async ({ name, content }) => { try { const postId = await dispatch(createPost({ installation: installation.id, visible: content.visible, text: content.text, cover: content.cover, attachments: content.attachments, data: content.data, parent: parent ? parent.id : undefined, })) postMessage({ name, content: { postId, } })
dispatch(showNotification(NotificationType.Success, `Posted!`)) if (onPost) onPost() } catch (error) { postMessage({ name, error, })
dispatch(showNotification(NotificationType.Error, `Error posting: ${error.message}`)) } }, data, 2000)
break } }
window.addEventListener('message', listener, false)
return () => { window.removeEventListener('message', listener, false) } }, [installation, parent, error])
const handleClick = (id: string) => { if (installation && installation.id === id) { dispatch(setSelectedInstallation()) return }
dispatch(setSelectedInstallation(id)) }
return ( <div className="composer-container" style={{ borderColor: theme.backgroundSecondary }}> <div className="composer"> {showComposer && <iframe ref={ref} src={composerUrl} scrolling="yes" style={{ height }} /> } {error && <div className="composer-error" style={{ backgroundColor: theme.backgroundSecondary, color: theme.red }}>Composer Error: {error}</div>} {(!showComposer && !error) && <div className="composer-empty" style={{ backgroundColor: theme.backgroundSecondary, color: theme.secondary }}>Choose an App.</div>} </div>
<div className="installations" style={{ backgroundColor: theme.backgroundPrimary }}> {installations.map(i => ( <div key={i.id} className={installation && installation.id === i.id ? 'selected' : ''} style={{ borderColor: theme.backgroundSecondary }} onClick={() => handleClick(i.id)}> <img src={i.app.iconImageUrl} alt={i.app.name} style={{ width: 32 }} /> <p style={{ color: theme.text }}>{i.app.name}</p> </div> ))} </div> </div> ) }
export default Composer
|