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 === "😀",
|
||||
},
|
||||
};
|
||||
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user