Files
cinny/src/app/features/room/widgets/WidgetsPanel.tsx
T
jaredandClaude Opus 4.8 101e4116e8 refactor(DP15): centralize sendStateEvent as-any cast in typed helper
Add a typed `sendStateEvent(mx, roomId, eventType, content, stateKey?)`
helper in utils/room.ts that mirrors the DP16 account-data helper pattern.
The SDK's typed `sendStateEvent` overload rejects the fork's custom
`StateEvent` enum values, so every call site cast arg 2 to `any` (which
also collapsed the content type). The single `as any` cast now lives inside
the helper; a generic `content: T extends object` keeps each call site's
content type checked.

Route all 32 `mx.sendStateEvent(..., StateEvent.X as any, ...)` casts across
20 files through the helper. The 2 dynamic-string casts in developer-tools
(SendRoomEvent, StateEventEditor) pass a runtime string, not an enum value,
so they stay as-is.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:32:50 -04:00

278 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { sendStateEvent } from '../../../utils/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 apps 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 sendStateEvent(mx, room.roomId, StateEvent.Widget, content, 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
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, 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>
);
}