Allow embedding HTML for external actions (#2693)

* Admin UI: implement HTML embeds

* Admin UI External Actions: set correct useHTML on edits

* Admin UI: edit by index, not URL

* External Actions: render HTML on stream frontend

* Don't open embeds externally

* Remove TODO comment

* Add HTML as unique action key

* Admin UI: Actions: use CodeMirror editor, dropdown
This commit is contained in:
Philipp 2023-02-14 18:08:54 +01:00 committed by GitHub
parent c372c9b36e
commit a290770ac9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 120 additions and 38 deletions

View file

@ -4,6 +4,8 @@ package models
type ExternalAction struct { type ExternalAction struct {
// URL is the URL to load. // URL is the URL to load.
URL string `json:"url"` URL string `json:"url"`
// HTML is the HTML to embed into the modal. When this is set, OpenExternally and URL are ignored
HTML string `json:"html"`
// Title is the name of this action, displayed in the modal. // Title is the name of this action, displayed in the modal.
Title string `json:"title"` Title string `json:"title"`
// Description is the description of this action. // Description is the description of this action.

View file

@ -75,7 +75,7 @@ const OwncastPlayer = dynamic(
); );
const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => { const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => {
const { title, description, url } = externalActionToDisplay; const { title, description, url, html } = externalActionToDisplay;
return ( return (
<Modal <Modal
title={description || title} title={description || title}
@ -83,7 +83,19 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
open={!!externalActionToDisplay} open={!!externalActionToDisplay}
height="80vh" height="80vh"
handleCancel={() => setExternalActionToDisplay(null)} handleCancel={() => setExternalActionToDisplay(null)}
/> >
{html ? (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
style={{
height: '100%',
width: '100%',
overflow: 'auto',
}}
/>
) : null}
</Modal>
); );
}; };
@ -127,7 +139,8 @@ export const Content: FC = () => {
const externalActionSelected = (action: ExternalAction) => { const externalActionSelected = (action: ExternalAction) => {
const { openExternally, url } = action; const { openExternally, url } = action;
if (openExternally) { // apply openExternally only if we don't have an HTML embed
if (openExternally && url) {
window.open(url, '_blank'); window.open(url, '_blank');
} else { } else {
setExternalActionToDisplay(action); setExternalActionToDisplay(action);
@ -136,7 +149,7 @@ export const Content: FC = () => {
const externalActionButtons = externalActions.map(action => ( const externalActionButtons = externalActions.map(action => (
<ActionButton <ActionButton
key={action.url} key={action.url || action.html}
action={action} action={action}
externalActionSelected={externalActionSelected} externalActionSelected={externalActionSelected}
/> />

View file

@ -11,3 +11,4 @@
background-color: var(--theme-color-components-modal-content-background); background-color: var(--theme-color-components-modal-content-background);
color: var(--theme-color-components-modal-content-text); color: var(--theme-color-components-modal-content-text);
} }

View file

@ -2,7 +2,8 @@ export interface ExternalAction {
title: string; title: string;
description?: string; description?: string;
color?: string; color?: string;
url: string; url?: string;
html?: string;
icon?: string; icon?: string;
openExternally?: boolean; openExternally?: boolean;
} }

1
web/package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "4.8.0", "@ant-design/icons": "4.8.0",
"@codemirror/lang-css": "6.0.2", "@codemirror/lang-css": "6.0.2",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2",
"@codemirror/lang-markdown": "6.0.5", "@codemirror/lang-markdown": "6.0.5",
"@codemirror/language-data": "6.1.0", "@codemirror/language-data": "6.1.0",

View file

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "4.8.0", "@ant-design/icons": "4.8.0",
"@codemirror/lang-css": "6.0.2", "@codemirror/lang-css": "6.0.2",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2",
"@codemirror/lang-markdown": "6.0.5", "@codemirror/lang-markdown": "6.0.5",
"@codemirror/language-data": "6.1.0", "@codemirror/language-data": "6.1.0",

View file

@ -1,5 +1,7 @@
import { Button, Checkbox, Form, Input, Modal, Space, Table, Typography } from 'antd'; import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Typography } from 'antd';
import _ from 'lodash'; import CodeMirror from '@uiw/react-codemirror';
import { bbedit } from '@uiw/codemirror-theme-bbedit';
import { html as codeMirrorHTML } from '@codemirror/lang-html';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import React, { ReactElement, useContext, useEffect, useState } from 'react'; import React, { ReactElement, useContext, useEffect, useState } from 'react';
import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator'; import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator';
@ -33,7 +35,9 @@ interface Props {
onCancel: () => void; onCancel: () => void;
onOk: ( onOk: (
oldAction: ExternalAction | null, oldAction: ExternalAction | null,
oldActionIndex: number | null,
actionUrl: string, actionUrl: string,
actionHTML: string,
actionTitle: string, actionTitle: string,
actionDescription: string, actionDescription: string,
actionIcon: string, actionIcon: string,
@ -42,12 +46,19 @@ interface Props {
) => void; ) => void;
open: boolean; open: boolean;
action: ExternalAction | null; action: ExternalAction | null;
index: number | null;
} }
// ActionType is only used here to save either only the URL or only the HTML.
type ActionType = 'url' | 'html';
const ActionModal = (props: Props) => { const ActionModal = (props: Props) => {
const { onOk, onCancel, open, action } = props; const { onOk, onCancel, open, action } = props;
const [actionType, setActionType] = useState<ActionType>('url');
const [actionUrl, setActionUrl] = useState(''); const [actionUrl, setActionUrl] = useState('');
const [actionHTML, setActionHTML] = useState('');
const [actionTitle, setActionTitle] = useState(''); const [actionTitle, setActionTitle] = useState('');
const [actionDescription, setActionDescription] = useState(''); const [actionDescription, setActionDescription] = useState('');
const [actionIcon, setActionIcon] = useState(''); const [actionIcon, setActionIcon] = useState('');
@ -55,7 +66,9 @@ const ActionModal = (props: Props) => {
const [openExternally, setOpenExternally] = useState(false); const [openExternally, setOpenExternally] = useState(false);
useEffect(() => { useEffect(() => {
setActionType((action?.html?.length || 0) > 0 ? 'html' : 'url');
setActionUrl(action?.url || ''); setActionUrl(action?.url || '');
setActionHTML(action?.html || '');
setActionTitle(action?.title || ''); setActionTitle(action?.title || '');
setActionDescription(action?.description || ''); setActionDescription(action?.description || '');
setActionIcon(action?.icon || ''); setActionIcon(action?.icon || '');
@ -66,7 +79,10 @@ const ActionModal = (props: Props) => {
function save() { function save() {
onOk( onOk(
action, action,
actionUrl, props.index,
// Save only one of the properties
actionType === 'html' ? '' : actionUrl,
actionType === 'html' ? actionHTML : '',
actionTitle, actionTitle,
actionDescription, actionDescription,
actionIcon, actionIcon,
@ -74,6 +90,7 @@ const ActionModal = (props: Props) => {
openExternally, openExternally,
); );
setActionUrl(''); setActionUrl('');
setActionHTML('');
setActionTitle(''); setActionTitle('');
setActionDescription(''); setActionDescription('');
setActionIcon(''); setActionIcon('');
@ -82,6 +99,9 @@ const ActionModal = (props: Props) => {
} }
function canSave(): Boolean { function canSave(): Boolean {
if (actionType === 'html') {
return actionHTML !== '' && actionTitle !== '';
}
return isValidUrl(actionUrl, ['https:']) && actionTitle !== ''; return isValidUrl(actionUrl, ['https:']) && actionTitle !== '';
} }
@ -93,6 +113,10 @@ const ActionModal = (props: Props) => {
setOpenExternally(checkbox.target.checked); setOpenExternally(checkbox.target.checked);
}; };
const onActionHTMLChanged = (newActionHTML: string) => {
setActionHTML(newActionHTML);
};
return ( return (
<Modal <Modal
destroyOnClose destroyOnClose
@ -104,7 +128,7 @@ const ActionModal = (props: Props) => {
> >
<Form initialValues={action}> <Form initialValues={action}>
Add the URL for the external action you want to present.{' '} Add the URL for the external action you want to present.{' '}
<strong>Only HTTPS urls are supported.</strong> <strong>Only HTTPS URLs and embeds are supported.</strong>
<p> <p>
<a <a
href="https://owncast.online/thirdparty/actions/" href="https://owncast.online/thirdparty/actions/"
@ -114,15 +138,39 @@ const ActionModal = (props: Props) => {
Read more about external actions. Read more about external actions.
</a> </a>
</p> </p>
<Form.Item name="url"> <Form.Item>
<Input <Select
required value={actionType}
placeholder="https://myserver.com/action (required)" onChange={setActionType}
onChange={input => setActionUrl(input.currentTarget.value.trim())} placeholder="Select an action type"
type="url" options={[
pattern={DEFAULT_TEXTFIELD_URL_PATTERN} { label: 'Link or embed an URL', value: 'url' },
{ label: 'Custom HTML', value: 'html' },
]}
/> />
</Form.Item> </Form.Item>
{actionType === 'html' ? (
<Form.Item name="html">
<CodeMirror
value={actionHTML}
placeholder="HTML embed code (required)"
theme={bbedit}
height="200px"
extensions={[codeMirrorHTML()]}
onChange={onActionHTMLChanged}
/>
</Form.Item>
) : (
<Form.Item name="url">
<Input
required
placeholder="https://myserver.com/action (required)"
onChange={input => setActionUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
</Form.Item>
)}
<Form.Item name="title"> <Form.Item name="title">
<Input <Input
value={actionTitle} value={actionTitle}
@ -155,15 +203,17 @@ const ActionModal = (props: Props) => {
</Form.Item> </Form.Item>
Optional background color of the action button. Optional background color of the action button.
</div> </div>
<Form.Item name="openExternally"> {actionType === 'html' ? null : (
<Checkbox <Form.Item name="openExternally">
checked={openExternally} <Checkbox
defaultChecked={openExternally} checked={openExternally}
onChange={onOpenExternallyChanged} defaultChecked={openExternally}
> onChange={onOpenExternallyChanged}
Open in a new tab instead of within your page. >
</Checkbox> Open in a new tab instead of within your page.
</Form.Item> </Checkbox>
</Form.Item>
)}
</Form> </Form>
</Modal> </Modal>
); );
@ -177,6 +227,7 @@ const Actions = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null); const [submitStatus, setSubmitStatus] = useState(null);
const [editAction, setEditAction] = useState<ExternalAction>(null); const [editAction, setEditAction] = useState<ExternalAction>(null);
const [editActionIndex, setEditActionIndex] = useState(-1);
const resetStates = () => { const resetStates = () => {
setSubmitStatus(null); setSubmitStatus(null);
@ -205,9 +256,8 @@ const Actions = () => {
}); });
} }
async function handleDelete(action) { async function handleDelete(action, index) {
const actionsData = [...actions]; const actionsData = [...actions];
const index = actions.findIndex(item => item.url === action.url);
actionsData.splice(index, 1); actionsData.splice(index, 1);
try { try {
@ -220,7 +270,9 @@ const Actions = () => {
async function handleSave( async function handleSave(
oldAction: ExternalAction | null, oldAction: ExternalAction | null,
oldActionIndex: number,
url: string, url: string,
html: string,
title: string, title: string,
description: string, description: string,
icon: string, icon: string,
@ -232,6 +284,7 @@ const Actions = () => {
const newAction: ExternalAction = { const newAction: ExternalAction = {
url, url,
html,
title, title,
description, description,
icon, icon,
@ -240,9 +293,8 @@ const Actions = () => {
}; };
// Replace old action if edited or append the new action // Replace old action if edited or append the new action
const index = oldAction ? actions.findIndex(item => _.isEqual(item, oldAction)) : -1; if (oldActionIndex >= 0) {
if (index >= 0) { actionsData[oldActionIndex] = newAction;
actionsData[index] = newAction;
} else { } else {
actionsData.push(newAction); actionsData.push(newAction);
} }
@ -254,19 +306,23 @@ const Actions = () => {
} }
} }
async function handleEdit(action: ExternalAction) { async function handleEdit(action: ExternalAction, index) {
setEditActionIndex(index);
setEditAction(action); setEditAction(action);
setIsModalOpen(true); setIsModalOpen(true);
} }
const showCreateModal = () => { const showCreateModal = () => {
setEditAction(null); setEditAction(null);
setEditActionIndex(-1);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleModalSaveButton = ( const handleModalSaveButton = (
oldAction: ExternalAction | null, oldAction: ExternalAction | null,
oldActionIndex: number,
actionUrl: string, actionUrl: string,
actionHTML: string,
actionTitle: string, actionTitle: string,
actionDescription: string, actionDescription: string,
actionIcon: string, actionIcon: string,
@ -276,7 +332,9 @@ const Actions = () => {
setIsModalOpen(false); setIsModalOpen(false);
handleSave( handleSave(
oldAction, oldAction,
oldActionIndex,
actionUrl, actionUrl,
actionHTML,
actionTitle, actionTitle,
actionDescription, actionDescription,
actionIcon, actionIcon,
@ -284,6 +342,7 @@ const Actions = () => {
openExternally, openExternally,
); );
setEditAction(null); setEditAction(null);
setEditActionIndex(-1);
}; };
const handleModalCancelButton = () => { const handleModalCancelButton = () => {
@ -294,10 +353,10 @@ const Actions = () => {
{ {
title: '', title: '',
key: 'delete-edit', key: 'delete-edit',
render: (text, record) => ( render: (text, record, index) => (
<Space size="middle"> <Space size="middle">
<Button onClick={() => handleDelete(record)} icon={<DeleteOutlined />} /> <Button onClick={() => handleDelete(record, index)} icon={<DeleteOutlined />} />
<Button onClick={() => handleEdit(record)} icon={<EditOutlined />} /> <Button onClick={() => handleEdit(record, index)} icon={<EditOutlined />} />
</Space> </Space>
), ),
}, },
@ -312,9 +371,10 @@ const Actions = () => {
key: 'description', key: 'description',
}, },
{ {
title: 'URL', title: 'URL / Embed',
dataIndex: 'url',
key: 'url', key: 'url',
dataIndex: 'url',
render: (text, record) => (record.html ? 'HTML embed' : record.url),
}, },
{ {
title: 'Icon', title: 'Icon',
@ -331,9 +391,11 @@ const Actions = () => {
}, },
{ {
title: 'Opens', title: 'Opens',
dataIndex: 'openExternally',
key: 'openExternally', key: 'openExternally',
render: (openExternally: boolean) => (openExternally ? 'In a new tab' : 'In a modal'), dataIndex: 'openExternally',
// Note: embeds will alway open in the same tab / in a modal
render: (openExternally: boolean, record) =>
!openExternally || record.html ? 'In the same tab' : 'In a new tab',
}, },
]; ];
@ -370,6 +432,7 @@ const Actions = () => {
<ActionModal <ActionModal
action={editAction} action={editAction}
index={editActionIndex}
open={isModalOpen} open={isModalOpen}
onOk={handleModalSaveButton} onOk={handleModalSaveButton}
onCancel={handleModalCancelButton} onCancel={handleModalCancelButton}