diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/hide-quick-reactions-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/hide-quick-reactions-auto.png new file mode 100644 index 0000000000..367e184b0e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/hide-quick-reactions-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/status/CustomStatusView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/status/CustomStatusView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..f2f3b296e3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/status/CustomStatusView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx index c0788217f6..8f67ddc773 100644 --- a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx @@ -45,3 +45,9 @@ export const DisabledEmoji: Story = { isEmojiDisabled: (unicode: string) => unicode === "😀", }, }; + +export const HideQuickReactions: Story = { + args: { + showQuickReactions: false, + }, +}; diff --git a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx index eb9aa532b6..1903a860bc 100644 --- a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx @@ -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(undefined); @@ -468,11 +474,16 @@ export function EmojiPicker({ /> )} - {previewEmoji ? ( - - ) : ( - - )} + {showQuickReactions && + (previewEmoji ? ( + + ) : ( + + ))} )} diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index c2ec670137..d93dfba100 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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", diff --git a/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap b/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap index f80116c261..7c08d70dff 100644 --- a/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap +++ b/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap @@ -380,6 +380,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = ` 🌴 Away +
  • + ✍️ Custom… + +
  • @@ -806,6 +814,14 @@ exports[`UserMenu > renders an open menu 1`] = ` 🌴 Away +
  • + ✍️ Custom… + +
  • diff --git a/packages/shared-components/src/status/CustomStatusView.module.css b/packages/shared-components/src/status/CustomStatusView.module.css new file mode 100644 index 0000000000..f0d759c28b --- /dev/null +++ b/packages/shared-components/src/status/CustomStatusView.module.css @@ -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; +} diff --git a/packages/shared-components/src/status/CustomStatusView.stories.tsx b/packages/shared-components/src/status/CustomStatusView.stories.tsx new file mode 100644 index 0000000000..ecd76b0abf --- /dev/null +++ b/packages/shared-components/src/status/CustomStatusView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/status/CustomStatusView.test.tsx b/packages/shared-components/src/status/CustomStatusView.test.tsx new file mode 100644 index 0000000000..c041dfdda5 --- /dev/null +++ b/packages/shared-components/src/status/CustomStatusView.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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" }); + }); +}); diff --git a/packages/shared-components/src/status/CustomStatusView.tsx b/packages/shared-components/src/status/CustomStatusView.tsx new file mode 100644 index 0000000000..9616bf3792 --- /dev/null +++ b/packages/shared-components/src/status/CustomStatusView.tsx @@ -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) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + commit(); + } + }, + [commit], + ); + + const onSubmit = useCallback( + (ev: React.FormEvent) => { + 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 ( + + + {emoji} + + } + > + setPickerOpen(false)} + showQuickReactions={false} + /> + + + setText(ev.currentTarget.value)} + placeholder={_t("status|set_status|set_status_prompt")} + aria-label={_t("status|set_status|set_status_prompt")} + autoFocus + /> + + + {trimmed ? _t("action|save") : _t("action|cancel")} + + + ); +} diff --git a/packages/shared-components/src/status/SetStatusView.test.tsx b/packages/shared-components/src/status/SetStatusView.test.tsx new file mode 100644 index 0000000000..734a2df830 --- /dev/null +++ b/packages/shared-components/src/status/SetStatusView.test.tsx @@ -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 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 { + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/packages/shared-components/src/status/SetStatusView.tsx b/packages/shared-components/src/status/SetStatusView.tsx index 675f536611..43e690bada 100644 --- a/packages/shared-components/src/status/SetStatusView.tsx +++ b/packages/shared-components/src/status/SetStatusView.tsx @@ -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 ; } + if (customMode) { + return ( + { + setCustomMode(false); + vm.setStatus(status); + }} + onCancel={() => setCustomMode(false)} + /> + ); + } + const renderTrigger = (props: DropdownTriggerProps): JSX.Element => { const trigger = (
    @@ -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 }) ) : ( [s.textKey, `${s.emoji} ${_t(s.textKey)}`])} + values={dropdownValues} label={null} placeholder={null} trigger={renderTrigger}