Files
blap/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx
T
Michael Telatynski 354a05d89f Remove dependency on uuid (#33230)
* Remove dependency on `uuid`

* Delint
2026-04-21 16:36:27 +00:00

336 lines
13 KiB
TypeScript

/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ChangeEvent, useContext, useEffect, useMemo, useState } from "react";
import { Pill } from "@element-hq/web-shared-components";
import { MatrixEvent, type IContent, RoomStickyEventsEvent } from "matrix-js-sdk/src/matrix";
import { Alert, Form, SettingsToggleInput } from "@vector-im/compound-web";
import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool.tsx";
import { _t, _td, UserFriendlyError } from "../../../../languageHandler.tsx";
import {
EventEditor,
eventTypeField,
EventViewer,
type IEditorProps,
stickyDurationField,
stringify,
} from "./Event.tsx";
import Field from "../../elements/Field.tsx";
import MatrixClientContext from "../../../../contexts/MatrixClientContext.tsx";
import InlineSpinner from "../../elements/InlineSpinner.tsx";
import { Key } from "../../../../Keyboard.ts";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo.ts";
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter.ts";
/**
* Devtool to explore sticky events in the current room.
* It allows you to see all sticky events, filter them by type, and view their content.
* @param onBack - handle back navigation in devtools
* @param setTool - callback to switch to a different devtool (StickyEventEditor) when the user wants to send a new sticky event
*/
export const StickyStateExplorer: React.FC<IDevtoolsProps> = ({ onBack, setTool }) => {
const context = useContext(DevtoolsContext);
const [eventType, setEventType] = useState<string>();
const [event, setEvent] = useState<MatrixEvent>();
const cli = useContext(MatrixClientContext);
// Check if the server supports sticky events and show a message if it doesn't.
// undefined means we are still checking, true/false means we have the result.
const stickyEventsSupported = useAsyncMemo(() => {
return cli.doesServerSupportUnstableFeature("org.matrix.msc4354");
}, [cli]);
// Listen for updates to the sticky events and refresh the list when they change
const events = useTypedEventEmitterState(context.room, RoomStickyEventsEvent.Update, () => {
return [...context.room._unstable_getStickyEvents()];
});
if (stickyEventsSupported === false) {
return (
<p>
<Alert
type="critical"
title={_t("common|error")}
actions={<button onClick={onBack}>{_t("action|back")}</button>}
>
{_t("devtools|sticky_events_not_supported")}
</Alert>
</p>
);
} else if (stickyEventsSupported === undefined) {
return (
<BaseTool onBack={onBack} onAction={async () => {}} actionLabel={_td("devtools|send_custom_sticky_event")}>
<p>
<InlineSpinner />
{_t("devtools|checking_sticky_events_support")}
</p>
</BaseTool>
);
}
// If an event is selected, show the single event view, which allows viewing the content of the event
// and sending a new one with the same sticky key.
if (event) {
return renderSingleEvent(setEvent, event);
}
// If an event type is selected, show the list of events of that type,
// with a filter and the option to show/hide "empty" events (empty sticky event is a way to clear state).
if (eventType !== undefined /* event type can be empty string */) {
return (
<StickyEventListPerType
eventType={eventType.length > 0 ? eventType : _t("devtools|empty_string")}
setTool={setTool}
events={events.filter((ev) => ev.getType() === eventType)}
onBack={() => setEventType(undefined)}
setEvent={setEvent}
/>
);
}
// Get the list of different types.
const uniqueEventTypes = Array.from(new Set(events.map((event) => event.getType())));
if (uniqueEventTypes.length === 0) {
return <p>{_t("devtools|no_sticky_events")}</p>;
}
const onAction = async (): Promise<void> => {
setTool(_td("devtools|send_custom_sticky_event"), StickyEventEditor);
};
return (
<BaseTool onBack={onBack} actionLabel={_td("devtools|send_custom_sticky_event")} onAction={onAction}>
<p>
{uniqueEventTypes.map((eventType) => (
<button key={eventType} className="mx_DevTools_button" onClick={() => setEventType(eventType)}>
{eventType.length > 0 ? eventType : _t("devtools|empty_string")}
</button>
))}
</p>
</BaseTool>
);
};
interface StateEventButtonProps {
userId: string;
stickyKey?: string;
expiresAt: number;
onClick(this: void): void;
}
/**
* A single row in the sticky event list, showing the userId, sticky key and time until expiration for a sticky event.
* @param userId - the sender of the sticky event
* @param stickyKey - the sticky key of the event
* @param expiresAt - the timestamp when the sticky event will expire
* @param onClick - callback to show the event details when the row is clicked
*/
const StickyEventTableLine: React.FC<StateEventButtonProps> = ({ userId, stickyKey, expiresAt, onClick }) => {
const [timeRemainingFormatted, setTimeRemainingFormatted] = useState<string>("");
useEffect(() => {
const updateCountdown = (): void => {
const now = Date.now();
const remaining = expiresAt - now;
if (remaining <= 0) {
setTimeRemainingFormatted(_t("devtools|expired"));
return;
}
// Calculate time remaining
const totalSeconds = Math.floor(remaining / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
// Format the display
if (days > 0) {
setTimeRemainingFormatted(`${days}d ${hours}h ${minutes}m`);
} else if (hours > 0) {
setTimeRemainingFormatted(`${hours}h ${minutes}m ${seconds}s`);
} else if (minutes > 0) {
setTimeRemainingFormatted(`${minutes}m ${seconds}s`);
} else {
setTimeRemainingFormatted(`${seconds}s`);
}
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return (
<tr
onClick={onClick}
onKeyDown={(e) => {
// Activate on Enter or Space for keyboard users
if (e.key === Key.ENTER || e.key === Key.SPACE) {
onClick();
}
}}
tabIndex={0}
role="button"
>
<td>{userId}</td>
<td>{stickyKey ?? <i>unkeyed</i>}</td>
<td className="remaining_time_column">{timeRemainingFormatted}</td>
</tr>
);
};
interface StickyEventListPerTypeProps {
eventType: string;
events: MatrixEvent[];
onBack: () => void;
setEvent: (event: MatrixEvent | undefined) => void;
setTool: IDevtoolsProps["setTool"];
}
const StickyEventListPerType: React.FC<StickyEventListPerTypeProps> = ({
eventType,
events,
onBack,
setEvent,
setTool,
}) => {
const onAction = async (): Promise<void> => {
setTool(_td("devtools|send_custom_sticky_event"), StickyEventEditor);
};
const [query, setQuery] = useState("");
const [showEmptyState, setShowEmptyState] = useState(true);
return (
<BaseTool
className="mx_DevTools_sticky_explorer"
onBack={onBack}
actionLabel={_td("devtools|send_custom_sticky_event")}
onAction={onAction}
>
<p>
<Pill label={eventType} />
</p>
<Field
label={_t("common|filter_results")}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={query}
onChange={(ev: ChangeEvent<HTMLInputElement>) => setQuery(ev.target.value)}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsToggleInput
name="decrypted_toggle"
label={_t("devtools|show_empty_content_events")}
onChange={(e) => setShowEmptyState(e.target.checked)}
checked={showEmptyState}
/>
</Form.Root>
<table className="mx_DevTools_event_table">
<thead>
<tr>
<th id="user_header">{_t("devtools|users")}</th>
<th id="sticky_key_header">{_t("devtools|sticky_key")}</th>
<th id="expires_in_header">{_t("devtools|expires_in")}</th>
</tr>
</thead>
<tbody>
{events
.filter((eventType) => {
if (showEmptyState) return true;
// An empty sticky event has a single content key "msc4354_sticky_key" and no other keys, as the sticky key is required but content can otherwise be empty
const contentKeys = Object.keys(eventType.getContent());
const isEmpty = contentKeys.length === 1 && contentKeys[0] === "msc4354_sticky_key";
return !isEmpty;
})
.filter((ev) => {
// No filtering, return all events
if (!query) return true;
// Filter by sender or sticky key
if (ev.getSender()!.includes(query)) {
return true;
}
const matchesStickyKey = ev.getContent().msc4354_sticky_key?.includes(query);
return !!matchesStickyKey;
})
.sort((a, b) => {
return (a.unstableStickyExpiresAt ?? 0) - (b.unstableStickyExpiresAt ?? 0);
})
.map((ev) => (
<StickyEventTableLine
key={ev.getId()}
userId={ev.getSender()!}
stickyKey={ev.getContent().msc4354_sticky_key}
expiresAt={ev.unstableStickyExpiresAt!}
onClick={() => setEvent(ev)}
/>
))}
</tbody>
</table>
</BaseTool>
);
};
function renderSingleEvent(setEvent: (value: MatrixEvent | undefined) => void, event: MatrixEvent): React.JSX.Element {
const _onBack = (): void => {
setEvent(undefined);
};
// If the event is encrypted, getEffectiveEvent will return the event
// as it would appear if it was unencrypted.
const effectiveEvent = event.getEffectiveEvent();
const clear = new MatrixEvent(effectiveEvent);
return <EventViewer mxEvent={clear} onBack={_onBack} Editor={StickyEventEditor} />;
}
export const StickyEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(
() => [eventTypeField(mxEvent?.getType()), stickyDurationField(3600000 /* 1 hour in ms */)],
[mxEvent],
);
const onSend = async ([eventType, stickyDuration]: string[], content: IContent): Promise<void> => {
// Parse and validate stickyDuration. It must be an integer number of milliseconds
// between 0 and 3,600,000 (inclusive) — 1-hour max.
const parsed = Number.parseInt(String(stickyDuration), 10);
if (Number.isNaN(parsed)) {
throw new UserFriendlyError("devtools|error_sticky_duration_must_be_a_number");
}
if (parsed < 0 || parsed > 3600000) {
throw new UserFriendlyError("devtools|error_sticky_duration_out_of_range");
}
await cli._unstable_sendStickyEvent(context.room.roomId, parsed, null, eventType as any, content);
};
const defaultContent = mxEvent
? stringify(mxEvent.getContent())
: stringify({
msc4354_sticky_key: window.crypto.randomUUID(),
});
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};