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:
co-authored by
Will Hunt
David Langley
parent
bb2083423b
commit
62b5cc65db
BIN
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -45,3 +45,9 @@ export const DisabledEmoji: Story = {
|
|||||||
isEmojiDisabled: (unicode: string) => unicode === "😀",
|
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.
|
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
|
||||||
*/
|
*/
|
||||||
getAction?: RovingTabIndexProviderProps["getAction"];
|
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 */
|
/** Convert recent emoji characters to emoji data, removing unknowns and duplicates */
|
||||||
@@ -193,6 +198,7 @@ export function EmojiPicker({
|
|||||||
recentEmojis,
|
recentEmojis,
|
||||||
onRecordRecent,
|
onRecordRecent,
|
||||||
getAction,
|
getAction,
|
||||||
|
showQuickReactions = true,
|
||||||
}: EmojiPickerProps): React.ReactNode {
|
}: EmojiPickerProps): React.ReactNode {
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [previewEmoji, setPreviewEmoji] = useState<IEmoji | undefined>(undefined);
|
const [previewEmoji, setPreviewEmoji] = useState<IEmoji | undefined>(undefined);
|
||||||
@@ -468,11 +474,16 @@ export function EmojiPicker({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AutoHideScrollbar>
|
</AutoHideScrollbar>
|
||||||
{previewEmoji ? (
|
{showQuickReactions &&
|
||||||
<Preview emoji={previewEmoji} />
|
(previewEmoji ? (
|
||||||
) : (
|
<Preview emoji={previewEmoji} />
|
||||||
<QuickReactions onClick={onClickEmoji} selectedEmojis={selectedEmojis} getAction={getAction} />
|
) : (
|
||||||
)}
|
<QuickReactions
|
||||||
|
onClick={onClickEmoji}
|
||||||
|
selectedEmojis={selectedEmojis}
|
||||||
|
getAction={getAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</RovingGridIndexProvider>
|
</RovingGridIndexProvider>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"cancel": "Cancel",
|
||||||
"click": "Click",
|
"click": "Click",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"reply_in_thread": "Reply in thread",
|
"reply_in_thread": "Reply in thread",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
|
"save": "Save",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"start_chat": "Start chat",
|
"start_chat": "Start chat",
|
||||||
"unpin": "Unpin",
|
"unpin": "Unpin",
|
||||||
@@ -218,6 +220,8 @@
|
|||||||
"set_status": {
|
"set_status": {
|
||||||
"away": "Away",
|
"away": "Away",
|
||||||
"be_right_back": "Be right back",
|
"be_right_back": "Be right back",
|
||||||
|
"choose_emoji": "Choose Emoji",
|
||||||
|
"custom": "Custom…",
|
||||||
"focus_time": "Focus Time",
|
"focus_time": "Focus Time",
|
||||||
"in_a_meeting": "In a meeting",
|
"in_a_meeting": "In a meeting",
|
||||||
"on_the_road": "On the road",
|
"on_the_road": "On the road",
|
||||||
|
|||||||
@@ -380,6 +380,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
|
|||||||
🌴 Away
|
🌴 Away
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
aria-selected="false"
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
✍️ Custom…
|
||||||
|
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -806,6 +814,14 @@ exports[`UserMenu > renders an open menu 1`] = `
|
|||||||
🌴 Away
|
🌴 Away
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
aria-selected="false"
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
✍️ Custom…
|
||||||
|
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
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 { Dropdown, type DropdownTriggerProps, Link, Text } from "@vector-im/compound-web";
|
||||||
import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { _t, _td, type UserStatus } from "..";
|
import { _t, _td, type UserStatus } from "..";
|
||||||
import { useViewModel, type ViewModel } from "../core/viewmodel";
|
import { useViewModel, type ViewModel } from "../core/viewmodel";
|
||||||
import { StatusPillView } from "./StatusPillView";
|
import { StatusPillView } from "./StatusPillView";
|
||||||
|
import { CustomStatusView } from "./CustomStatusView";
|
||||||
import styles from "./SetStatusView.module.css";
|
import styles from "./SetStatusView.module.css";
|
||||||
|
|
||||||
const PRESET_STATUSES = [
|
const PRESET_STATUSES = [
|
||||||
@@ -22,6 +23,9 @@ const PRESET_STATUSES = [
|
|||||||
{ emoji: "🌴", textKey: _td("status|set_status|away") },
|
{ 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 {
|
export interface SetStatusViewSnapshot {
|
||||||
/**
|
/**
|
||||||
* The current user status, or undefined if no status is set.
|
* 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 {
|
export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
|
||||||
const { userStatus } = useViewModel(vm);
|
const { userStatus } = useViewModel(vm);
|
||||||
|
const [customMode, setCustomMode] = useState(false);
|
||||||
|
|
||||||
if (userStatus) {
|
if (userStatus) {
|
||||||
return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />;
|
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 renderTrigger = (props: DropdownTriggerProps): JSX.Element => {
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<div className={styles.setStatusContainer}>
|
<div className={styles.setStatusContainer}>
|
||||||
@@ -81,6 +98,11 @@ export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onValueChange = (value: string): void => {
|
const onValueChange = (value: string): void => {
|
||||||
|
if (value === CUSTOM_STATUS_VALUE) {
|
||||||
|
setCustomMode(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const status = PRESET_STATUSES.find((s) => s.textKey === value);
|
const status = PRESET_STATUSES.find((s) => s.textKey === value);
|
||||||
|
|
||||||
if (!status) {
|
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 ? (
|
return vm.onSetStatusClick ? (
|
||||||
renderTrigger({ onClick: vm.onSetStatusClick })
|
renderTrigger({ onClick: vm.onSetStatusClick })
|
||||||
) : (
|
) : (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
values={PRESET_STATUSES.map((s) => [s.textKey, `${s.emoji} ${_t(s.textKey)}`])}
|
values={dropdownValues}
|
||||||
label={null}
|
label={null}
|
||||||
placeholder={null}
|
placeholder={null}
|
||||||
trigger={renderTrigger}
|
trigger={renderTrigger}
|
||||||
|
|||||||
Reference in New Issue
Block a user