277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
|
|
import React, { FormEventHandler, useState } from 'react';
|
|||
|
|
import {
|
|||
|
|
Box,
|
|||
|
|
Button,
|
|||
|
|
Header,
|
|||
|
|
Icon,
|
|||
|
|
IconButton,
|
|||
|
|
Icons,
|
|||
|
|
Input,
|
|||
|
|
Scroll,
|
|||
|
|
Spinner,
|
|||
|
|
Text,
|
|||
|
|
Tooltip,
|
|||
|
|
TooltipProvider,
|
|||
|
|
color,
|
|||
|
|
config,
|
|||
|
|
} from 'folds';
|
|||
|
|
import { Room } from 'matrix-js-sdk';
|
|||
|
|
import classNames from 'classnames';
|
|||
|
|
import * as css from './WidgetsPanel.css';
|
|||
|
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||
|
|
import { RoomWidgetView } from './RoomWidgetView';
|
|||
|
|
import { useRoomWidgets } from './useRoomWidgets';
|
|||
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||
|
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
|||
|
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
|||
|
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
|||
|
|
import { StateEvent } from '../../../../types/matrix/room';
|
|||
|
|
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
|||
|
|
|
|||
|
|
const urlErrorMessage = (err: WidgetUrlError): string => {
|
|||
|
|
switch (err) {
|
|||
|
|
case 'empty':
|
|||
|
|
return 'Enter a widget URL.';
|
|||
|
|
case 'not-https':
|
|||
|
|
return 'Widget URLs must use https.';
|
|||
|
|
case 'same-origin':
|
|||
|
|
return 'That URL is not allowed (it is on this app’s own origin).';
|
|||
|
|
default:
|
|||
|
|
return 'That is not a valid URL.';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type WidgetsPanelProps = {
|
|||
|
|
room: Room;
|
|||
|
|
requestClose: () => void;
|
|||
|
|
};
|
|||
|
|
export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||
|
|
const mx = useMatrixClient();
|
|||
|
|
const widgets = useRoomWidgets(room);
|
|||
|
|
const powerLevels = usePowerLevelsContext();
|
|||
|
|
const creators = useRoomCreators(room);
|
|||
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|||
|
|
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
|||
|
|
|
|||
|
|
const [viewingId, setViewingId] = useState<string | null>(null);
|
|||
|
|
const [adding, setAdding] = useState(false);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [error, setError] = useState<string>();
|
|||
|
|
|
|||
|
|
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
|||
|
|
|
|||
|
|
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
|||
|
|
evt.preventDefault();
|
|||
|
|
const target = evt.target as HTMLFormElement;
|
|||
|
|
const nameInput = target.elements.namedItem('widgetName') as HTMLInputElement | null;
|
|||
|
|
const urlInput = target.elements.namedItem('widgetUrl') as HTMLInputElement | null;
|
|||
|
|
if (!urlInput) return;
|
|||
|
|
const urlErr = validateWidgetUrl(urlInput.value, window.location.origin);
|
|||
|
|
if (urlErr) {
|
|||
|
|
setError(urlErrorMessage(urlErr));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setError(undefined);
|
|||
|
|
setSaving(true);
|
|||
|
|
const id = generateWidgetId();
|
|||
|
|
const content = {
|
|||
|
|
id,
|
|||
|
|
type: 'm.custom',
|
|||
|
|
url: urlInput.value.trim(),
|
|||
|
|
name: nameInput?.value.trim() || 'Widget',
|
|||
|
|
creatorUserId: mx.getSafeUserId(),
|
|||
|
|
data: {},
|
|||
|
|
};
|
|||
|
|
try {
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
|
|||
|
|
setAdding(false);
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleRemove = (id: string) => {
|
|||
|
|
if (viewingId === id) setViewingId(null);
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Box
|
|||
|
|
shrink="No"
|
|||
|
|
className={classNames(css.WidgetsPanel, ContainerColor({ variant: 'Surface' }))}
|
|||
|
|
direction="Column"
|
|||
|
|
>
|
|||
|
|
<Header className={css.WidgetsPanelHeader} variant="Background" size="600">
|
|||
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|||
|
|
<Box grow="Yes" direction="Column">
|
|||
|
|
<Text size="H5" truncate>
|
|||
|
|
Widgets
|
|||
|
|
</Text>
|
|||
|
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
|||
|
|
{room.name}
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
<Box shrink="No">
|
|||
|
|
<TooltipProvider
|
|||
|
|
position="Bottom"
|
|||
|
|
align="End"
|
|||
|
|
offset={4}
|
|||
|
|
tooltip={
|
|||
|
|
<Tooltip>
|
|||
|
|
<Text>Close</Text>
|
|||
|
|
</Tooltip>
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
{(triggerRef) => (
|
|||
|
|
<IconButton
|
|||
|
|
ref={triggerRef}
|
|||
|
|
variant="Background"
|
|||
|
|
aria-label="Close widgets"
|
|||
|
|
onClick={requestClose}
|
|||
|
|
>
|
|||
|
|
<Icon src={Icons.Cross} />
|
|||
|
|
</IconButton>
|
|||
|
|
)}
|
|||
|
|
</TooltipProvider>
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
</Header>
|
|||
|
|
|
|||
|
|
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
|||
|
|
{viewing ? (
|
|||
|
|
<Box grow="Yes" direction="Column">
|
|||
|
|
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
|||
|
|
<Button
|
|||
|
|
variant="Secondary"
|
|||
|
|
fill="Soft"
|
|||
|
|
size="300"
|
|||
|
|
radii="300"
|
|||
|
|
onClick={() => setViewingId(null)}
|
|||
|
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
|||
|
|
>
|
|||
|
|
<Text size="B300" truncate>
|
|||
|
|
{viewing.name || 'Widget'}
|
|||
|
|
</Text>
|
|||
|
|
</Button>
|
|||
|
|
</Box>
|
|||
|
|
<RoomWidgetView room={room} widget={viewing} />
|
|||
|
|
</Box>
|
|||
|
|
) : (
|
|||
|
|
<Scroll hideTrack visibility="Hover">
|
|||
|
|
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
|||
|
|
{widgets.length === 0 && (
|
|||
|
|
<Text size="T200" style={{ opacity: 0.65 }}>
|
|||
|
|
No widgets in this room yet.
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
{widgets.map((widget) => (
|
|||
|
|
<Box key={widget.id} alignItems="Center" gap="200">
|
|||
|
|
<Box
|
|||
|
|
as="button"
|
|||
|
|
type="button"
|
|||
|
|
grow="Yes"
|
|||
|
|
alignItems="Center"
|
|||
|
|
gap="200"
|
|||
|
|
onClick={() => setViewingId(widget.id)}
|
|||
|
|
style={{ cursor: 'pointer', minWidth: 0 }}
|
|||
|
|
>
|
|||
|
|
<Icon size="100" src={Icons.Category} />
|
|||
|
|
<Text size="T300" truncate>
|
|||
|
|
{widget.name || widget.templateUrl}
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
{canModify && (
|
|||
|
|
<IconButton
|
|||
|
|
size="300"
|
|||
|
|
radii="300"
|
|||
|
|
variant="Background"
|
|||
|
|
aria-label={`Remove ${widget.name || 'widget'}`}
|
|||
|
|
onClick={() => handleRemove(widget.id)}
|
|||
|
|
>
|
|||
|
|
<Icon size="100" src={Icons.Delete} />
|
|||
|
|
</IconButton>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{canModify &&
|
|||
|
|
(adding ? (
|
|||
|
|
<Box
|
|||
|
|
as="form"
|
|||
|
|
direction="Column"
|
|||
|
|
gap="200"
|
|||
|
|
onSubmit={handleAdd}
|
|||
|
|
style={{ marginTop: config.space.S200 }}
|
|||
|
|
>
|
|||
|
|
<Input
|
|||
|
|
name="widgetName"
|
|||
|
|
placeholder="Name (optional)"
|
|||
|
|
variant="Secondary"
|
|||
|
|
radii="300"
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
name="widgetUrl"
|
|||
|
|
placeholder="https://…"
|
|||
|
|
variant="Secondary"
|
|||
|
|
radii="300"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<Box gap="200">
|
|||
|
|
<Button
|
|||
|
|
type="submit"
|
|||
|
|
size="300"
|
|||
|
|
variant="Primary"
|
|||
|
|
fill="Solid"
|
|||
|
|
radii="300"
|
|||
|
|
disabled={saving}
|
|||
|
|
before={
|
|||
|
|
saving ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<Text size="B300">Add</Text>
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
size="300"
|
|||
|
|
variant="Secondary"
|
|||
|
|
fill="Soft"
|
|||
|
|
radii="300"
|
|||
|
|
onClick={() => {
|
|||
|
|
setAdding(false);
|
|||
|
|
setError(undefined);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text size="B300">Cancel</Text>
|
|||
|
|
</Button>
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
) : (
|
|||
|
|
<Button
|
|||
|
|
variant="Secondary"
|
|||
|
|
fill="Soft"
|
|||
|
|
size="300"
|
|||
|
|
radii="300"
|
|||
|
|
onClick={() => setAdding(true)}
|
|||
|
|
before={<Icon size="100" src={Icons.Plus} />}
|
|||
|
|
style={{ marginTop: config.space.S200 }}
|
|||
|
|
>
|
|||
|
|
<Text size="B300">Add Widget</Text>
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
{error && (
|
|||
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|||
|
|
{error}
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
</Scroll>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|