[ABANDONED] React/Redux front end for the Flexor social network.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

241 lines
9.1 KiB

// 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