// 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 . 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 = ({ 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() const ref = useRef(null) const [limiters, setLimiters] = useState({}) 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, 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 (
{showComposer &&