diff --git a/apps/web/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png b/apps/web/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png index 7f802aca0e5..c84c7465470 100644 Binary files a/apps/web/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png and b/apps/web/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png differ diff --git a/apps/web/res/css/views/dialogs/_DevtoolsDialog.pcss b/apps/web/res/css/views/dialogs/_DevtoolsDialog.pcss index efcf4db372a..b691863d865 100644 --- a/apps/web/res/css/views/dialogs/_DevtoolsDialog.pcss +++ b/apps/web/res/css/views/dialogs/_DevtoolsDialog.pcss @@ -187,3 +187,53 @@ Please see LICENSE files in the repository root for full details. /* used on focus */ color: $links !important; } + +.mx_DevTools_sticky_explorer { + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + margin-top: var(--cpd-space-3x); + + th { + text-align: left; + padding: var(--cpd-space-2x) var(--cpd-space-3x); + } + + th#user_header { + width: 35%; + } + th#sticky_key_header { + width: 50%; + } + th#expires_in_header { + width: 15%; + } + + tr { + cursor: pointer; + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary); + background: transparent; + } + + tr:hover { + color: var(--cpd-color-text-secondary); + background: var(--cpd-color-bg-action-secondary-hovered); + } + + tr:focus-visible { + outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); + } + + td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: var(--cpd-space-2x) var(--cpd-space-3x); + } + + td.remaining_time_column { + text-align: right; + } + } +} diff --git a/apps/web/src/components/views/dialogs/DevtoolsDialog.tsx b/apps/web/src/components/views/dialogs/DevtoolsDialog.tsx index 93ffd8c0d3e..fd006ec264c 100644 --- a/apps/web/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/apps/web/src/components/views/dialogs/DevtoolsDialog.tsx @@ -28,6 +28,7 @@ import CopyableText from "../elements/CopyableText"; import RoomNotifications from "./devtools/RoomNotifications"; import { Crypto } from "./devtools/Crypto"; import SettingsField from "../elements/SettingsField.tsx"; +import { StickyStateExplorer } from "./devtools/StickyEventState.tsx"; enum Category { Room, @@ -49,6 +50,7 @@ const Tools: Record = { [_td("devtools|notifications_debug"), RoomNotifications], [_td("devtools|active_widgets"), WidgetExplorer], [_td("devtools|users"), UserList], + [_td("devtools|explore_sticky_state"), StickyStateExplorer], ], [Category.Other]: [ [_td("devtools|explore_account_data"), AccountDataExplorer], diff --git a/apps/web/src/components/views/dialogs/devtools/Event.tsx b/apps/web/src/components/views/dialogs/devtools/Event.tsx index c205e89986f..d8aeee932cf 100644 --- a/apps/web/src/components/views/dialogs/devtools/Event.tsx +++ b/apps/web/src/components/views/dialogs/devtools/Event.tsx @@ -45,6 +45,12 @@ export const stateKeyField = (defaultValue?: string): IFieldDef => ({ default: defaultValue, }); +export const stickyDurationField = (defaultValue?: number): IFieldDef => ({ + id: "sticky_duration", + label: _td("devtools|sticky_duration"), + default: `${defaultValue ?? 360000}`, +}); + const validateEventContent = withValidation({ async deriveData({ value }) { try { diff --git a/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx b/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx new file mode 100644 index 00000000000..159fbc113bf --- /dev/null +++ b/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx @@ -0,0 +1,336 @@ +/* + * 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 { v4 as uuidv4 } from "uuid"; + +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 = ({ onBack, setTool }) => { + const context = useContext(DevtoolsContext); + const [eventType, setEventType] = useState(); + const [event, setEvent] = useState(); + + 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 ( +

+ {_t("action|back")}} + > + {_t("devtools|sticky_events_not_supported")} + +

+ ); + } else if (stickyEventsSupported === undefined) { + return ( + {}} actionLabel={_td("devtools|send_custom_sticky_event")}> +

+ + {_t("devtools|checking_sticky_events_support")} +

+
+ ); + } + + // 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 ( + 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

{_t("devtools|no_sticky_events")}

; + } + + const onAction = async (): Promise => { + setTool(_td("devtools|send_custom_sticky_event"), StickyEventEditor); + }; + return ( + +

+ {uniqueEventTypes.map((eventType) => ( + + ))} +

+
+ ); +}; + +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 = ({ userId, stickyKey, expiresAt, onClick }) => { + const [timeRemainingFormatted, setTimeRemainingFormatted] = useState(""); + + 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 ( + { + // Activate on Enter or Space for keyboard users + if (e.key === Key.ENTER || e.key === Key.SPACE) { + onClick(); + } + }} + tabIndex={0} + role="button" + > + {userId} + {stickyKey ?? unkeyed} + {timeRemainingFormatted} + + ); +}; + +interface StickyEventListPerTypeProps { + eventType: string; + events: MatrixEvent[]; + onBack: () => void; + setEvent: (event: MatrixEvent | undefined) => void; + setTool: IDevtoolsProps["setTool"]; +} + +const StickyEventListPerType: React.FC = ({ + eventType, + events, + onBack, + setEvent, + setTool, +}) => { + const onAction = async (): Promise => { + setTool(_td("devtools|send_custom_sticky_event"), StickyEventEditor); + }; + + const [query, setQuery] = useState(""); + const [showEmptyState, setShowEmptyState] = useState(true); + + return ( + +

+ +

+ + ) => setQuery(ev.target.value)} + className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" + /> + + { + evt.preventDefault(); + evt.stopPropagation(); + }} + > + setShowEmptyState(e.target.checked)} + checked={showEmptyState} + /> + + + + + + + + + + + + {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) => ( + setEvent(ev)} + /> + ))} + +
{_t("devtools|users")}{_t("devtools|sticky_key")}{_t("devtools|expires_in")}
+
+ ); +}; + +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 ; +} + +export const StickyEventEditor: React.FC = ({ 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 => { + // 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: uuidv4(), + }); + return ; +}; diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 75f323a920c..bde259e9206 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -751,6 +751,7 @@ "category_other": "Other", "category_room": "Room", "caution_colon": "Caution:", + "checking_sticky_events_support": "Checking for support for sticky events...", "client_versions": "Client Versions", "crypto": { "4s_public_key_in_account_data": "in account data", @@ -809,13 +810,18 @@ "edit_setting": "Edit setting", "edit_values": "Edit values", "empty_string": "", + "error_sticky_duration_must_be_a_number": "stickyDuration must be a number", + "error_sticky_duration_out_of_range": "stickyDuration must be between 0 and 36000 milliseconds (1h)", "event_content": "Event Content", "event_id": "Event ID: %(eventId)s", "event_sent": "Event sent!", "event_type": "Event Type", + "expired": "Expired", + "expires_in": "Expires in", "explore_account_data": "Explore account data", "explore_room_account_data": "Explore room account data", "explore_room_state": "Explore room state", + "explore_sticky_state": "Explore sticky state", "failed_to_find_widget": "There was an error finding this widget.", "failed_to_load": "Failed to load.", "failed_to_save": "Failed to save settings.", @@ -829,6 +835,7 @@ "main_timeline": "Main timeline", "manual_device_verification": "Manual device verification", "no_receipt_found": "No receipt found", + "no_sticky_events": "There are no sticky events in this room.", "notification_state": "Notification state is %(notificationState)s", "notifications_debug": "Notifications debug", "number_of_users": "Number of users", @@ -855,6 +862,7 @@ "send_custom_account_data_event": "Send custom account data event", "send_custom_room_account_data_event": "Send custom room account data event", "send_custom_state_event": "Send custom state event", + "send_custom_sticky_event": "Send custom sticky event", "send_custom_timeline_event": "Send custom timeline event", "server_info": "Server info", "server_versions": "Server Versions", @@ -874,6 +882,9 @@ "other": "<%(count)s spaces>" }, "state_key": "State Key", + "sticky_duration": "Sticky Duration (ms)", + "sticky_events_not_supported": "Your homeserver does not support sticky events yet.", + "sticky_key": "Sticky Key", "thread_root_id": "Thread Root ID: %(threadRootId)s", "threads_timeline": "Threads timeline", "title": "Developer tools", diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index c6a5296e64b..c756084986e 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -103,6 +103,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > Users +