feat: Devtool for sticky events MSC4354 (#32741)
* feat: Devtool for sticky events MSC4354 * Update devtool snapshot to add sticky state devtool * Update devtool playwright screenshot * review: Use UserFriendlyError instead or Error * review: fix docs * review: remove css in js, remove js hover tracking * review: use keyboard enums * add a check to see if homeserver supports sticky events * fixup: prettier * review: No static inline styles * review: use cpd spacing / border / color values * cleanup keyboard code * Fix unsupported alert look * review: proper useState usage (no | null) * review: useAsyncMemo instead of useEffect * review: use useTypedEventEmitterState * fix: better support for empty string event type * review: remove redundant expired state
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
@@ -187,3 +187,53 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
/* used on focus */
|
/* used on focus */
|
||||||
color: $links !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import CopyableText from "../elements/CopyableText";
|
|||||||
import RoomNotifications from "./devtools/RoomNotifications";
|
import RoomNotifications from "./devtools/RoomNotifications";
|
||||||
import { Crypto } from "./devtools/Crypto";
|
import { Crypto } from "./devtools/Crypto";
|
||||||
import SettingsField from "../elements/SettingsField.tsx";
|
import SettingsField from "../elements/SettingsField.tsx";
|
||||||
|
import { StickyStateExplorer } from "./devtools/StickyEventState.tsx";
|
||||||
|
|
||||||
enum Category {
|
enum Category {
|
||||||
Room,
|
Room,
|
||||||
@@ -49,6 +50,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
|
|||||||
[_td("devtools|notifications_debug"), RoomNotifications],
|
[_td("devtools|notifications_debug"), RoomNotifications],
|
||||||
[_td("devtools|active_widgets"), WidgetExplorer],
|
[_td("devtools|active_widgets"), WidgetExplorer],
|
||||||
[_td("devtools|users"), UserList],
|
[_td("devtools|users"), UserList],
|
||||||
|
[_td("devtools|explore_sticky_state"), StickyStateExplorer],
|
||||||
],
|
],
|
||||||
[Category.Other]: [
|
[Category.Other]: [
|
||||||
[_td("devtools|explore_account_data"), AccountDataExplorer],
|
[_td("devtools|explore_account_data"), AccountDataExplorer],
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export const stateKeyField = (defaultValue?: string): IFieldDef => ({
|
|||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const stickyDurationField = (defaultValue?: number): IFieldDef => ({
|
||||||
|
id: "sticky_duration",
|
||||||
|
label: _td("devtools|sticky_duration"),
|
||||||
|
default: `${defaultValue ?? 360000}`,
|
||||||
|
});
|
||||||
|
|
||||||
const validateEventContent = withValidation<any, Error | undefined>({
|
const validateEventContent = withValidation<any, Error | undefined>({
|
||||||
async deriveData({ value }) {
|
async deriveData({ value }) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<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: uuidv4(),
|
||||||
|
});
|
||||||
|
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
|
||||||
|
};
|
||||||
@@ -751,6 +751,7 @@
|
|||||||
"category_other": "Other",
|
"category_other": "Other",
|
||||||
"category_room": "Room",
|
"category_room": "Room",
|
||||||
"caution_colon": "Caution:",
|
"caution_colon": "Caution:",
|
||||||
|
"checking_sticky_events_support": "Checking for support for sticky events...",
|
||||||
"client_versions": "Client Versions",
|
"client_versions": "Client Versions",
|
||||||
"crypto": {
|
"crypto": {
|
||||||
"4s_public_key_in_account_data": "in account data",
|
"4s_public_key_in_account_data": "in account data",
|
||||||
@@ -809,13 +810,18 @@
|
|||||||
"edit_setting": "Edit setting",
|
"edit_setting": "Edit setting",
|
||||||
"edit_values": "Edit values",
|
"edit_values": "Edit values",
|
||||||
"empty_string": "<empty string>",
|
"empty_string": "<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_content": "Event Content",
|
||||||
"event_id": "Event ID: %(eventId)s",
|
"event_id": "Event ID: %(eventId)s",
|
||||||
"event_sent": "Event sent!",
|
"event_sent": "Event sent!",
|
||||||
"event_type": "Event Type",
|
"event_type": "Event Type",
|
||||||
|
"expired": "Expired",
|
||||||
|
"expires_in": "Expires in",
|
||||||
"explore_account_data": "Explore account data",
|
"explore_account_data": "Explore account data",
|
||||||
"explore_room_account_data": "Explore room account data",
|
"explore_room_account_data": "Explore room account data",
|
||||||
"explore_room_state": "Explore room state",
|
"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_find_widget": "There was an error finding this widget.",
|
||||||
"failed_to_load": "Failed to load.",
|
"failed_to_load": "Failed to load.",
|
||||||
"failed_to_save": "Failed to save settings.",
|
"failed_to_save": "Failed to save settings.",
|
||||||
@@ -829,6 +835,7 @@
|
|||||||
"main_timeline": "Main timeline",
|
"main_timeline": "Main timeline",
|
||||||
"manual_device_verification": "Manual device verification",
|
"manual_device_verification": "Manual device verification",
|
||||||
"no_receipt_found": "No receipt found",
|
"no_receipt_found": "No receipt found",
|
||||||
|
"no_sticky_events": "There are no sticky events in this room.",
|
||||||
"notification_state": "Notification state is <strong>%(notificationState)s</strong>",
|
"notification_state": "Notification state is <strong>%(notificationState)s</strong>",
|
||||||
"notifications_debug": "Notifications debug",
|
"notifications_debug": "Notifications debug",
|
||||||
"number_of_users": "Number of users",
|
"number_of_users": "Number of users",
|
||||||
@@ -855,6 +862,7 @@
|
|||||||
"send_custom_account_data_event": "Send custom account data event",
|
"send_custom_account_data_event": "Send custom account data event",
|
||||||
"send_custom_room_account_data_event": "Send custom room 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_state_event": "Send custom state event",
|
||||||
|
"send_custom_sticky_event": "Send custom sticky event",
|
||||||
"send_custom_timeline_event": "Send custom timeline event",
|
"send_custom_timeline_event": "Send custom timeline event",
|
||||||
"server_info": "Server info",
|
"server_info": "Server info",
|
||||||
"server_versions": "Server Versions",
|
"server_versions": "Server Versions",
|
||||||
@@ -874,6 +882,9 @@
|
|||||||
"other": "<%(count)s spaces>"
|
"other": "<%(count)s spaces>"
|
||||||
},
|
},
|
||||||
"state_key": "State Key",
|
"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",
|
"thread_root_id": "Thread Root ID: %(threadRootId)s",
|
||||||
"threads_timeline": "Threads timeline",
|
"threads_timeline": "Threads timeline",
|
||||||
"title": "Developer tools",
|
"title": "Developer tools",
|
||||||
|
|||||||
+5
@@ -103,6 +103,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
|
|||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx_DevTools_button"
|
||||||
|
>
|
||||||
|
Explore sticky state
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
|
|||||||
Reference in New Issue
Block a user