Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
adcba32
feat: Devtool for sticky events MSC4354
BillCarsonFr Mar 5, 2026
335a5df
Update devtool snapshot to add sticky state devtool
BillCarsonFr Mar 6, 2026
37f768d
Merge branch 'develop' into valere/devtool_sticky_event
BillCarsonFr Mar 6, 2026
424129a
Update devtool playwright screenshot
BillCarsonFr Mar 6, 2026
15945ed
review: Use UserFriendlyError instead or Error
BillCarsonFr Mar 6, 2026
1a09bba
review: fix docs
BillCarsonFr Mar 6, 2026
eabe6b2
review: remove css in js, remove js hover tracking
BillCarsonFr Mar 6, 2026
2249c6a
review: use keyboard enums
BillCarsonFr Mar 6, 2026
0d458b7
add a check to see if homeserver supports sticky events
BillCarsonFr Mar 6, 2026
3eef477
Merge branch 'develop' into valere/devtool_sticky_event
BillCarsonFr Mar 6, 2026
6a7a5d7
fixup: prettier
BillCarsonFr Mar 6, 2026
b43fb2a
review: No static inline styles
BillCarsonFr Mar 9, 2026
6e41f5b
review: use cpd spacing / border / color values
BillCarsonFr Mar 9, 2026
7f7cd1b
cleanup keyboard code
BillCarsonFr Mar 10, 2026
ad58b21
Fix unsupported alert look
BillCarsonFr Mar 10, 2026
03638a7
Merge branch 'develop' into valere/devtool_sticky_event
BillCarsonFr Mar 10, 2026
ec460b2
review: proper useState usage (no | null)
BillCarsonFr Mar 10, 2026
63e49ac
review: useAsyncMemo instead of useEffect
BillCarsonFr Mar 10, 2026
a27cb71
review: use useTypedEventEmitterState
BillCarsonFr Mar 10, 2026
4522f18
fix: better support for empty string event type
BillCarsonFr Mar 10, 2026
fa9039c
Merge branch 'develop' into valere/devtool_sticky_event
BillCarsonFr Mar 10, 2026
d0d00d4
Merge branch 'develop' into valere/devtool_sticky_event
BillCarsonFr Mar 13, 2026
b626701
review: remove redundant expired state
BillCarsonFr Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/web/src/components/views/dialogs/DevtoolsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +50,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
[_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],
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/views/dialogs/devtools/Event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, Error | undefined>({
async deriveData({ value }) {
try {
Expand Down
364 changes: 364 additions & 0 deletions apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
/*
* 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 { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { v4 as uuidv4 } from "uuid";
import { logger } from "nx/src/utils/logger";

import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool.tsx";
import { _t, _td } 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";

/**
* 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
* @param setTool
* @constructor
*/
export const StickyStateExplorer: React.FC<IDevtoolsProps> = ({ onBack, setTool }) => {
const context = useContext(DevtoolsContext);
const [eventType, setEventType] = useState<string | null>(null);
const [event, setEvent] = useState<MatrixEvent | null>(null);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preferred way is this

Suggested change
const [eventType, setEventType] = useState<string | null>(null);
const [event, setEvent] = useState<MatrixEvent | null>(null);
const [eventType, setEventType] = useState<string>();
const [event, setEvent] = useState<MatrixEvent>();

and the type will be T | undefined


const [events, setEvents] = useState<MatrixEvent[]>(() => [...context.room._unstable_getStickyEvents()]);

// Listen for updates to the sticky events and refresh the list when they change
useEffect(() => {
const refresh = (): void => setEvents([...context.room._unstable_getStickyEvents()]);
context.room.on(RoomStickyEventsEvent.Update, refresh);

return () => {
context.room.off(RoomStickyEventsEvent.Update, refresh);
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should use useTypedEventEmitterState

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done here a27cb71

}, [context.room]);

// 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) {
return (
<StickyEventListPerType
eventType={eventType}
setTool={setTool}
events={events.filter((ev) => ev.getType() === eventType)}
onBack={() => setEventType(null)}
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> => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why's this async? Also probably ought to be useCallback().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is because BaseTool IProps expects onAction(this: void): Promise<string | 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}
</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 [timeRemaining, setTimeRemaining] = useState<string>("");
const [isExpired, setIsExpired] = useState<boolean>(false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This state feels a bit redundant? Isn't it just a function of timeRemaining?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, done b626701

const [hover, setHover] = useState<boolean>(false);
const [focused, setFocused] = useState<boolean>(false);
// showFocus indicates the focus outline should be shown - we set it on keyboard interaction
const [showFocus, setShowFocus] = useState<boolean>(false);

useEffect(() => {
const updateCountdown = (): void => {
const now = Date.now();
const remaining = expiresAt - now;

if (remaining <= 0) {
setIsExpired(true);
setTimeRemaining("");
return;
}

setIsExpired(false);

// 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) {
setTimeRemaining(`${days}d ${hours}h ${minutes}m`);
} else if (hours > 0) {
setTimeRemaining(`${hours}h ${minutes}m ${seconds}s`);
} else if (minutes > 0) {
setTimeRemaining(`${minutes}m ${seconds}s`);
} else {
setTimeRemaining(`${seconds}s`);
}
};

updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, [expiresAt]);

// Small reusable cell styles used to keep layout tidy in this devtool
// Give more space to the userId column to avoid truncation in typical cases
const userCellStyle: React.CSSProperties = {
maxWidth: "36ch",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
padding: "8px 12px",
};
const keyCellStyle: React.CSSProperties = {
maxWidth: "48ch",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
padding: "8px 12px",
};
const expiresCellStyle: React.CSSProperties = { textAlign: "right", whiteSpace: "nowrap", padding: "8px 12px" };

const rowStyle: React.CSSProperties = {
cursor: "pointer",
background: hover ? "rgba(0,0,0,0.03)" : "transparent",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be using :hover selector in CSS rather than JS hover tracking. Also probably needs keyboard accessibility styling rather than just mouse pointer hover

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done for hover.
I don't know what is keyboard accessibility styling? maybe it is using :focus css ? can you give me some advice to fix it?

Copy link
Copy Markdown
Member

@t3chguy t3chguy Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:focus-visible - :focus also applies to cursor where you already have :hover. So if you click it you leave :focus on it whereas :focus-visible only applies to forms of focus which should display a visible highlight for a11y

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed most of that code 7f7cd1b
keyboard navigation is still working.
Hope it fixes the problem? I really don't know much about css

borderBottom: "1px solid rgba(0,0,0,0.06)",
};
const focusStyle: React.CSSProperties = { outline: "2px solid rgba(0,120,212,0.25)", outlineOffset: "2px" };

return (
<tr
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseDown={() => setShowFocus(false)}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
setShowFocus(false);
}}
onKeyDown={(e) => {
// show focus when using keyboard keys (Tab/Enter/Space)
if (e.key === "Tab" || e.key === "Enter" || e.key === " ") setShowFocus(true);
// Activate on Enter or Space for keyboard users
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
tabIndex={0}
role="button"
style={{ ...rowStyle, ...(focused && showFocus ? focusStyle : {}) }}
>

Check warning on line 203 in apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <input type="button">, <input type="image">, <input type="reset">, <input type="submit">, or <button> instead of the "button" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzCyOF0ytpodCISuISp&open=AZzCyOF0ytpodCISuISp&pullRequest=32741
<td style={userCellStyle}>{userId}</td>
<td style={keyCellStyle}>{stickyKey ?? <i>unkeyed</i>}</td>
<td style={expiresCellStyle}>{isExpired ? _t("devtools|expired") : timeRemaining}</td>
</tr>
);
};

interface StickyEventListPerTypeProps {
eventType: string;
events: MatrixEvent[];
onBack: () => void;
setEvent: (event: MatrixEvent | null) => 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 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 style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed", marginTop: 8 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "8px 12px", width: "35%" }}>{_t("devtools|users")}</th>
<th style={{ textAlign: "left", padding: "8px 12px", width: "50%" }}>
{_t("devtools|sticky_key")}
</th>
<th style={{ textAlign: "right", padding: "8px 12px", width: "15%" }}>
{_t("devtools|expires_in")}
</th>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSS please.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done here eabe6b2
I kept just the width part that change for each column, I have seen it done in other parts of the code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept just the width part that change for each column, I have seen it done in other parts of the code

That doesn't make it right unfortunately, we have a lot of tech debt given the age of the codebase

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

</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)) {
logger.log(`Filtering by sender ${ev.getSender()} matched query ${query}`);
return true;
}
if (ev.getContent().msc4354_sticky_key?.includes(query)) {
logger.log(
`Filtering by sticky key ${ev.getContent().msc4354_sticky_key} matched query ${query}`,
);
return true;
}

logger.log(`Filtering did not match query ${query}`);
return false;
})
.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 | null) => void, event: MatrixEvent): React.JSX.Element {
const _onBack = (): void => {
setEvent(null);
};

// 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 Error("stickyDuration must be a number");

Check warning on line 349 in apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`new Error()` is too unspecific for a type check. Use `new TypeError()` instead.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzCyOF0ytpodCISuISq&open=AZzCyOF0ytpodCISuISq&pullRequest=32741
}
if (parsed < 0 || parsed > 3600000) {
throw new Error(`stickyDuration must be between 0 and 36000 milliseconds (1h)`);
}

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} />;
};
Loading
Loading