Custom user status (#34386)

* Exploration of a virtuoso-powered emoji picker

moved to shared components

Fable generated

* fix pnpm lock

* format & fix some lint issues

* wrong import

* fix lint warning

* Fix off-by-one

and remove manual overflow adjustment: let's leave the default unless
it turns out to be necessary. Emoji should not take that long to load.

* Convert to functional component

* WIP: change to one big virtuoso scroller

* Change to use virtuoso's own onRangeChanged

and santitise category data and how it's passed around

* Convert Tabs to functional component

and put the focusing behaviour back with it just keeping track of
refs by itself.

* Absorb two line config file into main component

* Actually add the config to the main file

* Convert emoji to functional

Also make selected always defined and use useCallback.

* QuickReactions to functional component

* Non-default exports & doc

* Search to functional component

* Well it seems to work just fine now

* Use ref prop

* fix lockfile AGAIN

* lint

* Remove default export

* Remove some mx_ classnames and fix the inputRef

to make the arrow keys in the search box work (well, work as much as
they ever did).

* Remove last of the mx_ id / classnames

(except the one in the test)

* Use useMemo to memoize

* No need to export props interface (I think?)

and fix comment now we don't do the mutation stuff anymore

* Fix test

* Fix axe violations & add screenshots

* Avoid comparing dom snapshots in test

* Allow more before or after, just compare order of the ones present in both.

* Switch existing usages to new emoji picker

and kill the old one with fire

* Unused stuff

* Remove i18n strings

* Fix some tests

* Update screenshots

* Fix test

by removing the last of the weird memoized-but-mutated data structure

* Experimental custom status

* Screenshot

* snapshots

* Update button label and fix test

* Move the string somewhere more sensible than 'a11y'

* i18n lint

* Give the emojis IDs so aria-activedescendant works

* Fix more tests

* Add a small wrapper emoji picker component

This lets us easily memoize the recent emojis when the emoji picker is opened.
Also it saves a bit of boilerplate.

* Remove old emojipicker css

* Typos

Co-authored-by: David Langley <davidl@element.io>

* Use compound constants

* Rethemendex

* Test for custom status

* Use catalog version for emojibase

* Add comments

* More comments

* Fix comment

* More comments

* more comments (and make them uniform)

* More comments

* Fix pnpm lock again

* Another comment

* Add option to hide quick reactions (and preview) bar

* Fix test

to use emoji not in the quick reactions bar

* Apply button types to new version

* Add comment

* Disable screenshot

as per comment

* Fix hover / background / border styles

* Screenshot

* Don't use the reactions row because it's gone now

* Trim overly verbose comment

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
This commit is contained in:
David Baker
2026-08-10 10:21:14 +00:00
committed by GitHub
co-authored by Will Hunt David Langley
parent bb2083423b
commit 62b5cc65db
12 changed files with 425 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -45,3 +45,9 @@ export const DisabledEmoji: Story = {
isEmojiDisabled: (unicode: string) => unicode === "😀",
},
};
export const HideQuickReactions: Story = {
args: {
showQuickReactions: false,
},
};
@@ -122,6 +122,11 @@ export interface EmojiPickerProps {
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
*/
getAction?: RovingTabIndexProviderProps["getAction"];
/**
* Whether to show the quick reactions at the bottom of the picker. Defaults to true.
* Previews of emoji are displayed in the same bar as will also be hidden when this is false.
*/
showQuickReactions?: boolean;
}
/** Convert recent emoji characters to emoji data, removing unknowns and duplicates */
@@ -193,6 +198,7 @@ export function EmojiPicker({
recentEmojis,
onRecordRecent,
getAction,
showQuickReactions = true,
}: EmojiPickerProps): React.ReactNode {
const [filter, setFilter] = useState("");
const [previewEmoji, setPreviewEmoji] = useState<IEmoji | undefined>(undefined);
@@ -468,11 +474,16 @@ export function EmojiPicker({
/>
)}
</AutoHideScrollbar>
{previewEmoji ? (
<Preview emoji={previewEmoji} />
) : (
<QuickReactions onClick={onClickEmoji} selectedEmojis={selectedEmojis} getAction={getAction} />
)}
{showQuickReactions &&
(previewEmoji ? (
<Preview emoji={previewEmoji} />
) : (
<QuickReactions
onClick={onClickEmoji}
selectedEmojis={selectedEmojis}
getAction={getAction}
/>
))}
</section>
)}
</RovingGridIndexProvider>
@@ -4,6 +4,7 @@
},
"action": {
"back": "Back",
"cancel": "Cancel",
"click": "Click",
"close": "Close",
"collapse": "Collapse",
@@ -28,6 +29,7 @@
"reply": "Reply",
"reply_in_thread": "Reply in thread",
"retry": "Retry",
"save": "Save",
"search": "Search",
"start_chat": "Start chat",
"unpin": "Unpin",
@@ -218,6 +220,8 @@
"set_status": {
"away": "Away",
"be_right_back": "Be right back",
"choose_emoji": "Choose Emoji",
"custom": "Custom…",
"focus_time": "Focus Time",
"in_a_meeting": "In a meeting",
"on_the_road": "On the road",
@@ -380,6 +380,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
🌴 Away
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
✍️ Custom…
</li>
</ul>
</div>
</div>
@@ -806,6 +814,14 @@ exports[`UserMenu > renders an open menu 1`] = `
🌴 Away
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
✍️ Custom…
</li>
</ul>
</div>
</div>
@@ -0,0 +1,53 @@
/*
* 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.
*/
.customStatus {
display: flex;
width: 100%;
flex-direction: row !important;
align-items: center;
gap: var(--cpd-space-2x);
/* Match the 28px tall box the trigger/pill float in so nothing shifts vertically */
min-height: 28px;
}
.emojiButton {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-bg-canvas-default);
font-size: var(--cpd-font-size-heading-sm);
line-height: 1;
cursor: pointer;
&:hover,
&.selected {
background: var(--cpd-color-bg-subtle-secondary);
border-color: var(--cpd-color-border-interactive-hover);
}
}
.textField {
min-width: 0;
flex-grow: 1;
}
.action {
flex-shrink: 0;
cursor: pointer;
}
/* The picker sizes itself (340x450); drop the menu's default padding so it fills the popover */
.pickerMenu {
padding: 0;
}
@@ -0,0 +1,26 @@
/*
* 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 { type Meta, type StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { CustomStatusView } from "./CustomStatusView";
const meta = {
title: "Status/CustomStatusView",
component: CustomStatusView,
tags: ["autodocs"],
args: {
onSave: fn(),
onCancel: fn(),
},
} satisfies Meta<typeof CustomStatusView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,76 @@
/*
* 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 from "react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@test-utils";
import { CustomStatusView } from "./CustomStatusView";
describe("CustomStatusView", () => {
it("shows the default emoji and a Cancel link while the text is empty", () => {
render(<CustomStatusView onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox")).toHaveValue("");
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
});
it("calls onCancel when the link is clicked with empty text", async () => {
const onCancel = vi.fn();
const onSave = vi.fn();
render(<CustomStatusView onCancel={onCancel} onSave={onSave} />);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("toggles the link to Save and commits the status once text is entered", async () => {
const onCancel = vi.fn();
const onSave = vi.fn();
render(<CustomStatusView onCancel={onCancel} onSave={onSave} />);
await userEvent.type(screen.getByRole("textbox"), "In a meeting");
const save = await screen.findByRole("button", { name: "Save" });
await userEvent.click(save);
expect(onSave).toHaveBeenCalledWith({ emoji: "😄", text: "In a meeting" });
expect(onCancel).not.toHaveBeenCalled();
});
it("trims whitespace and ignores whitespace-only text", async () => {
const onCancel = vi.fn();
const onSave = vi.fn();
render(<CustomStatusView onCancel={onCancel} onSave={onSave} />);
await userEvent.type(screen.getByRole("textbox"), " ");
// Still a Cancel link, as the trimmed text is empty
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("lets the user pick an emoji from the picker popover", async () => {
const onSave = vi.fn();
render(<CustomStatusView onSave={onSave} onCancel={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Choose Emoji" }));
await waitFor(() => expect(screen.getByLabelText("Emoji picker")).toBeInTheDocument());
await userEvent.click(await screen.findByText("😇"));
// Picker closes and the trigger now shows the chosen emoji
await waitFor(() => expect(screen.queryByTestId("mx_EmojiPicker")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: "Choose Emoji" })).toHaveTextContent("😇");
await userEvent.type(screen.getByRole("textbox", { name: "What's your status?" }), "Angelic");
await userEvent.click(await screen.findByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith({ emoji: "😇", text: "Angelic" });
});
});
@@ -0,0 +1,128 @@
/*
* 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 JSX, useCallback, useState } from "react";
import { Field, Link, Menu, Root, TextControl } from "@vector-im/compound-web";
import { _t, type UserStatus } from "..";
import { EmojiPicker } from "../core/EmojiPicker";
import styles from "./CustomStatusView.module.css";
import classNames from "classnames";
/**
* The emoji shown on the picker trigger before the user has chosen one.
*/
const DEFAULT_EMOJI = "😄";
export interface CustomStatusViewProps {
/**
* Called when the user commits a custom status, i.e. clicks "Save" with
* non-empty text.
*/
onSave: (status: UserStatus) => void;
/**
* Called when the user dismisses the editor without saving, i.e. clicks
* "Cancel" while the text is empty.
*/
onCancel: () => void;
}
/**
* Editor for composing a custom user status text and choosing an emoji.
*/
export function CustomStatusView({ onSave, onCancel }: CustomStatusViewProps): JSX.Element {
const [emoji, setEmoji] = useState(DEFAULT_EMOJI);
const [text, setText] = useState("");
const [pickerOpen, setPickerOpen] = useState(false);
const trimmed = text.trim();
const onChooseEmoji = useCallback((unicode: string): boolean => {
setEmoji(unicode);
setPickerOpen(false);
// Don't record custom-status emoji as recently used composer reactions.
return false;
}, []);
const commit = useCallback(() => {
if (trimmed) {
onSave({ emoji, text: trimmed });
} else {
onCancel();
}
}, [trimmed, emoji, onSave, onCancel]);
const onActionKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
commit();
}
},
[commit],
);
const onSubmit = useCallback(
(ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
// Only commit on submit (e.g. Enter) when there is something to save;
// an empty submit shouldn't silently cancel the editor.
if (trimmed) {
onSave({ emoji, text: trimmed });
}
},
[trimmed, emoji, onSave],
);
return (
<Root className={styles.customStatus} onSubmit={onSubmit}>
<Menu
open={pickerOpen}
onOpenChange={setPickerOpen}
title={_t("status|set_status|choose_emoji")}
showTitle={false}
align="start"
className={styles.pickerMenu}
trigger={
<button
type="button"
className={classNames(styles.emojiButton, { [styles.selected]: pickerOpen })}
aria-label={_t("status|set_status|choose_emoji")}
>
{emoji}
</button>
}
>
<EmojiPicker
onChoose={onChooseEmoji}
onFinished={() => setPickerOpen(false)}
showQuickReactions={false}
/>
</Menu>
<Field name="custom-status" className={styles.textField}>
<TextControl
value={text}
onChange={(ev) => setText(ev.currentTarget.value)}
placeholder={_t("status|set_status|set_status_prompt")}
aria-label={_t("status|set_status|set_status_prompt")}
autoFocus
/>
</Field>
<Link
kind="primary"
size="md"
role="button"
tabIndex={0}
className={styles.action}
onClick={commit}
onKeyDown={onActionKeyDown}
>
{trimmed ? _t("action|save") : _t("action|cancel")}
</Link>
</Root>
);
}
@@ -0,0 +1,71 @@
/*
* 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 from "react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@test-utils";
import { SetStatusView, type SetStatusViewActions, type SetStatusViewSnapshot } from "./SetStatusView";
import { MockViewModel } from "../core/viewmodel/MockViewModel";
class SetStatusViewModel extends MockViewModel<SetStatusViewSnapshot> implements SetStatusViewActions {
public setStatus = vi.fn();
public clearStatus = vi.fn();
}
/**
* Open the dropdown and pick the "Custom…" entry, leaving it ready to enter a
* custom status / pick an emoji.
*/
async function openCustomEditor(): Promise<void> {
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "✍️ Custom…" }));
}
describe("SetStatusView", () => {
it("lets the user type a custom status", async () => {
const vm = new SetStatusViewModel({});
render(<SetStatusView vm={vm} />);
await openCustomEditor();
await userEvent.type(screen.getByRole("textbox", { name: "What's your status?" }), "Happy");
await userEvent.click(await screen.findByRole("button", { name: "Save" }));
expect(vm.setStatus).toHaveBeenCalledWith({ emoji: "😄", text: "Happy" });
});
it("sets a custom status with an emoji chosen from the picker", async () => {
const vm = new SetStatusViewModel({});
render(<SetStatusView vm={vm} />);
await openCustomEditor();
await userEvent.click(screen.getByRole("button", { name: "Choose Emoji" }));
// Pick from the always-rendered quick reactions row
await userEvent.click(await screen.findByText("🤩"));
await userEvent.type(screen.getByRole("textbox", { name: "What's your status?" }), "Starstruck");
await userEvent.click(await screen.findByRole("button", { name: "Save" }));
expect(vm.setStatus).toHaveBeenCalledWith({ emoji: "🤩", text: "Starstruck" });
});
it("returns to the set status prompt if the custom editor is cancelled", async () => {
const vm = new SetStatusViewModel({});
render(<SetStatusView vm={vm} />);
await openCustomEditor();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(vm.setStatus).not.toHaveBeenCalled();
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
});
@@ -5,13 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { type JSX, useState } from "react";
import { Dropdown, type DropdownTriggerProps, Link, Text } from "@vector-im/compound-web";
import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t, _td, type UserStatus } from "..";
import { useViewModel, type ViewModel } from "../core/viewmodel";
import { StatusPillView } from "./StatusPillView";
import { CustomStatusView } from "./CustomStatusView";
import styles from "./SetStatusView.module.css";
const PRESET_STATUSES = [
@@ -22,6 +23,9 @@ const PRESET_STATUSES = [
{ emoji: "🌴", textKey: _td("status|set_status|away") },
];
// Sentinel value used to distinguish the "Custom…" dropdown entry from a preset.
const CUSTOM_STATUS_VALUE = "custom";
export interface SetStatusViewSnapshot {
/**
* The current user status, or undefined if no status is set.
@@ -56,11 +60,24 @@ export type SetStatusViewProps = {
export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
const { userStatus } = useViewModel(vm);
const [customMode, setCustomMode] = useState(false);
if (userStatus) {
return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />;
}
if (customMode) {
return (
<CustomStatusView
onSave={(status) => {
setCustomMode(false);
vm.setStatus(status);
}}
onCancel={() => setCustomMode(false)}
/>
);
}
const renderTrigger = (props: DropdownTriggerProps): JSX.Element => {
const trigger = (
<div className={styles.setStatusContainer}>
@@ -81,6 +98,11 @@ export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
};
const onValueChange = (value: string): void => {
if (value === CUSTOM_STATUS_VALUE) {
setCustomMode(true);
return;
}
const status = PRESET_STATUSES.find((s) => s.textKey === value);
if (!status) {
@@ -93,11 +115,16 @@ export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
});
};
const dropdownValues: Array<[string, string]> = [
...PRESET_STATUSES.map((s): [string, string] => [s.textKey, `${s.emoji} ${_t(s.textKey)}`]),
[CUSTOM_STATUS_VALUE, `✍️ ${_t("status|set_status|custom")}`],
];
return vm.onSetStatusClick ? (
renderTrigger({ onClick: vm.onSetStatusClick })
) : (
<Dropdown
values={PRESET_STATUSES.map((s) => [s.textKey, `${s.emoji} ${_t(s.textKey)}`])}
values={dropdownValues}
label={null}
placeholder={null}
trigger={renderTrigger}