User status in user menu (#33797)
* Add user status on user profile icon Currently unstyled & no tests * Style the user status icon * Update snapshot for avatar wrapper * More snapshot updates * add if braces * Split out user status functions to avoid circular dep which has the weird effect of just breaking jest's mocking. * type imports * Update imports * Update snapshot * Tests * baseline image * Just snapshot the component itself * User status in user menu first pass, unstyled * fix export * superstylin' * snapshots * Add story & test * Snapshots and re-use the repeated rules * Tests * Fix jest lcov projectRoot config * Change tooltip text to just 'clear' * Add comment & fix console warn * Remove all options screenshot as it's not really particularly useful as a preset story * remove stale screenshot * fix imports --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
co-authored by
Michael Telatynski
parent
44c540eca0
commit
3ab233940c
@@ -8,11 +8,12 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
|
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { type UserStatus } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||||
import { useTypedEventEmitter } from "./useEventEmitter";
|
import { useTypedEventEmitter } from "./useEventEmitter";
|
||||||
import { useFeatureEnabled } from "./useSettings";
|
import { useFeatureEnabled } from "./useSettings";
|
||||||
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
|
import { validateUserStatus } from "../utils/userStatus";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("useUserStatus");
|
const logger = rootLogger.getChild("useUserStatus");
|
||||||
|
|
||||||
@@ -32,9 +33,8 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
|
|||||||
if (syncedUserId !== userId) {
|
if (syncedUserId !== userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (syncProfile["org.matrix.msc4426.status"]) {
|
|
||||||
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
|
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import SettingsStore from "../settings/SettingsStore";
|
|||||||
import { reject, success } from "./utils";
|
import { reject, success } from "./utils";
|
||||||
import { UserFriendlyError } from "../languageHandler";
|
import { UserFriendlyError } from "../languageHandler";
|
||||||
import { TimelineRenderingType } from "../contexts/RoomContext";
|
import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||||
import { userStatusTextWithinMaxLength } from "../utils/userStatus";
|
import { setUserStatus, userStatusTextWithinMaxLength } from "../utils/userStatus";
|
||||||
|
|
||||||
export const statusCommand = new Command({
|
export const statusCommand = new Command({
|
||||||
command: "status",
|
command: "status",
|
||||||
@@ -40,7 +40,7 @@ export const statusCommand = new Command({
|
|||||||
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
|
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
|
||||||
}
|
}
|
||||||
return success(
|
return success(
|
||||||
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
|
setUserStatus(cli, {
|
||||||
emoji: emoji.segment,
|
emoji: emoji.segment,
|
||||||
text,
|
text,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ClientEvent,
|
ClientEvent,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
|
import { type UserStatus } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { type ActionPayload } from "../dispatcher/payloads";
|
import { type ActionPayload } from "../dispatcher/payloads";
|
||||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||||
@@ -24,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
|
|||||||
import { _t } from "../languageHandler";
|
import { _t } from "../languageHandler";
|
||||||
import { mediaFromMxc } from "../customisations/Media";
|
import { mediaFromMxc } from "../customisations/Media";
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
|
import { validateUserStatus } from "../utils/userStatus";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_STATUS_TEXT_BYTES = 256;
|
import { type UserStatus } from "@element-hq/web-shared-components";
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
export interface UserStatus {
|
// MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
|
||||||
emoji: string;
|
// so we truncate anything longer than that.
|
||||||
text: string;
|
const MAX_STATUS_TEXT_BYTES = 256;
|
||||||
}
|
|
||||||
|
|
||||||
export function userStatusTextWithinMaxLength(text: string): boolean {
|
export function userStatusTextWithinMaxLength(text: string): boolean {
|
||||||
const textEncoder = new TextEncoder();
|
const textEncoder = new TextEncoder();
|
||||||
@@ -34,3 +34,14 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
|
|||||||
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
|
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise<void> {
|
||||||
|
return client.setExtendedProfileProperty("org.matrix.msc4426.status", {
|
||||||
|
emoji: userStatus.emoji,
|
||||||
|
text: userStatus.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUserStatus(client: MatrixClient): Promise<void> {
|
||||||
|
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components";
|
import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||||
@@ -18,6 +19,7 @@ import { shouldShowFeedback } from "../../utils/Feedback";
|
|||||||
import { getHomePageUrl } from "../../utils/pages";
|
import { getHomePageUrl } from "../../utils/pages";
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { clearUserStatus } from "../../utils/userStatus";
|
||||||
|
|
||||||
// Matches maximum size of an avatar in the UserMenu
|
// Matches maximum size of an avatar in the UserMenu
|
||||||
const AVATAR_PX = 88;
|
const AVATAR_PX = 88;
|
||||||
@@ -42,7 +44,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
|||||||
expanded: !isPanelCollapsed,
|
expanded: !isPanelCollapsed,
|
||||||
manageAccountHref: accountManagementEndpoint,
|
manageAccountHref: accountManagementEndpoint,
|
||||||
showAvatar: isAuthenticated,
|
showAvatar: isAuthenticated,
|
||||||
statusEmoji: OwnProfileStore.instance.userStatus?.emoji,
|
userStatus: OwnProfileStore.instance.userStatus,
|
||||||
actions: {
|
actions: {
|
||||||
createAccount: !isAuthenticated,
|
createAccount: !isAuthenticated,
|
||||||
signIn: !isAuthenticated,
|
signIn: !isAuthenticated,
|
||||||
@@ -57,7 +59,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dispatcher: MatrixDispatcher,
|
private readonly dispatcher: MatrixDispatcher,
|
||||||
client: MatrixClient,
|
private readonly client: MatrixClient,
|
||||||
isPanelCollapsed: boolean,
|
isPanelCollapsed: boolean,
|
||||||
accountManagementEndpoint?: string,
|
accountManagementEndpoint?: string,
|
||||||
) {
|
) {
|
||||||
@@ -73,8 +75,8 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
|||||||
public readonly recalculateProfile = (): void => {
|
public readonly recalculateProfile = (): void => {
|
||||||
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
|
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
|
||||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
|
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
|
||||||
const statusEmoji = OwnProfileStore.instance.userStatus?.emoji;
|
const userStatus = OwnProfileStore.instance.userStatus;
|
||||||
this.snapshot.merge({ displayName, avatarUrl, statusEmoji });
|
this.snapshot.merge({ displayName, avatarUrl, userStatus });
|
||||||
};
|
};
|
||||||
|
|
||||||
public readonly setOpen = (isOpen: boolean): void => {
|
public readonly setOpen = (isOpen: boolean): void => {
|
||||||
@@ -128,4 +130,11 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
|||||||
action: Action.ViewUserSettings,
|
action: Action.ViewUserSettings,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public readonly clearStatus = (): void => {
|
||||||
|
this.setOpen(false);
|
||||||
|
clearUserStatus(this.client).catch((err) => {
|
||||||
|
logger.warn("Failed to clear user status", err);
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import {
|
|||||||
type DisambiguatedProfileViewActions,
|
type DisambiguatedProfileViewActions,
|
||||||
type DisambiguatedProfileViewSnapshot,
|
type DisambiguatedProfileViewSnapshot,
|
||||||
type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface,
|
type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface,
|
||||||
|
type UserStatus,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
import { type MouseEvent } from "react";
|
import { type MouseEvent } from "react";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
|
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
|
||||||
import UserIdentifier from "../../../../customisations/UserIdentifier";
|
import UserIdentifier from "../../../../customisations/UserIdentifier";
|
||||||
import { type UserStatus } from "../../../../utils/userStatus";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about a member for disambiguation purposes.
|
* Information about a member for disambiguation purposes.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ describe("/status", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client = stubClient();
|
client = stubClient();
|
||||||
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function run(args?: string) {
|
function run(args?: string) {
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ export function createTestClient(): MatrixClient {
|
|||||||
deleteRoomTag: jest.fn().mockResolvedValue({}),
|
deleteRoomTag: jest.fn().mockResolvedValue({}),
|
||||||
setRoomTag: jest.fn().mockResolvedValue({}),
|
setRoomTag: jest.fn().mockResolvedValue({}),
|
||||||
getExtendedProfileProperty: jest.fn(),
|
getExtendedProfileProperty: jest.fn(),
|
||||||
|
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
client.reEmitter = new ReEmitter(client);
|
client.reEmitter = new ReEmitter(client);
|
||||||
|
|||||||
+3
-3
@@ -7,19 +7,19 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
|||||||
class="mx_SpacePanel collapsed newUi"
|
class="mx_SpacePanel collapsed newUi"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_wrapper_11z86_8 mx_UserMenu"
|
class="_wrapper_bh5eg_8 mx_UserMenu"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-label="User menu"
|
aria-label="User menu"
|
||||||
class="_triggerButton_11z86_80"
|
class="_triggerButton_bh5eg_107"
|
||||||
data-state="closed"
|
data-state="closed"
|
||||||
id="radix-react-use-id-1"
|
id="radix-react-use-id-1"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_avatarWrapper_11z86_57"
|
class="_avatarWrapper_bh5eg_57"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-label="@test:test"
|
aria-label="@test:test"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
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 MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { clearUserStatus, setUserStatus, userStatusTextWithinMaxLength } from "../../../src/utils/userStatus";
|
||||||
|
import { stubClient } from "../../test-utils";
|
||||||
|
|
||||||
|
describe("userStatus utils", () => {
|
||||||
|
describe("userStatusTextWithinMaxLength", () => {
|
||||||
|
it("returns true for text within the max length", () => {
|
||||||
|
const text = "a".repeat(256);
|
||||||
|
expect(userStatusTextWithinMaxLength(text)).toBe(true);
|
||||||
|
});
|
||||||
|
it("returns false for text exceeding the max length", () => {
|
||||||
|
const text = "a".repeat(257);
|
||||||
|
expect(userStatusTextWithinMaxLength(text)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setUserStatus", () => {
|
||||||
|
let client: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = stubClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the user status with valid input", async () => {
|
||||||
|
setUserStatus(client, { emoji: "🐳", text: "Feeling a little blue" });
|
||||||
|
|
||||||
|
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
|
||||||
|
emoji: "🐳",
|
||||||
|
text: "Feeling a little blue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearUserStatus", () => {
|
||||||
|
let client: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = stubClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the user status", async () => {
|
||||||
|
clearUserStatus(client);
|
||||||
|
|
||||||
|
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ describe("UserMenuViewModel", () => {
|
|||||||
...mockClientMethodsUser(),
|
...mockClientMethodsUser(),
|
||||||
...mockClientMethodsServer(),
|
...mockClientMethodsServer(),
|
||||||
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
|
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
|
||||||
|
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
SdkContextClass.instance.client = client;
|
SdkContextClass.instance.client = client;
|
||||||
});
|
});
|
||||||
@@ -153,6 +154,15 @@ describe("UserMenuViewModel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can clear a user status", async () => {
|
||||||
|
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||||
|
vm.setOpen(true);
|
||||||
|
vm.clearStatus();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should be able to open the createAccount screen as a guest", async () => {
|
it("should be able to open the createAccount screen as a guest", async () => {
|
||||||
client.isGuest.mockReturnValue(true);
|
client.isGuest.mockReturnValue(true);
|
||||||
const dispatcherSpy = jest.fn();
|
const dispatcherSpy = jest.fn();
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
|
|||||||
"manageAccountHref": undefined,
|
"manageAccountHref": undefined,
|
||||||
"open": true,
|
"open": true,
|
||||||
"showAvatar": false,
|
"showAvatar": false,
|
||||||
"statusEmoji": undefined,
|
|
||||||
"userId": "@alice:domain",
|
"userId": "@alice:domain",
|
||||||
|
"userStatus": undefined,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ exports[`UserMenuViewModel should generate a menu options for a logged in client
|
|||||||
"manageAccountHref": undefined,
|
"manageAccountHref": undefined,
|
||||||
"open": true,
|
"open": true,
|
||||||
"showAvatar": true,
|
"showAvatar": true,
|
||||||
"statusEmoji": undefined,
|
|
||||||
"userId": "@alice:domain",
|
"userId": "@alice:domain",
|
||||||
|
"userStatus": undefined,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tuple of an emoji and string representing a user's MSC4426 status.
|
||||||
|
* The emoji should be a single grapheme cluster.
|
||||||
|
*/
|
||||||
|
export interface UserStatus {
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
},
|
},
|
||||||
"menus": {
|
"menus": {
|
||||||
"user_menu": {
|
"user_menu": {
|
||||||
|
"clear_status": "Clear",
|
||||||
"create_an_account": "Create an account",
|
"create_an_account": "Create an account",
|
||||||
"got_an_account": "Got an account?",
|
"got_an_account": "Got an account?",
|
||||||
"manage_account": "Manage account",
|
"manage_account": "Manage account",
|
||||||
|
|||||||
@@ -88,5 +88,6 @@ export * from "./core/utils/numbers";
|
|||||||
export * from "./core/utils/FormattingUtils";
|
export * from "./core/utils/FormattingUtils";
|
||||||
export * from "./core/i18n/I18nApi";
|
export * from "./core/i18n/I18nApi";
|
||||||
export * from "./core/utils/linkify";
|
export * from "./core/utils/linkify";
|
||||||
|
export type * from "./core/userStatus.ts";
|
||||||
// MVVM
|
// MVVM
|
||||||
export * from "./core/viewmodel";
|
export * from "./core/viewmodel";
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusEmoji {
|
.iconStatusEmoji {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -77,6 +77,33 @@
|
|||||||
background-color: var(--cpd-color-bg-canvas-default);
|
background-color: var(--cpd-color-bg-canvas-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusButton {
|
||||||
|
border-radius: 30px;
|
||||||
|
background: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline-start: var(--cpd-space-2x);
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
.menuStatusEmoji {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuStatusText {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font: var(--cpd-font-body-md-medium);
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.triggerButton {
|
.triggerButton {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
|
|||||||
openHomePage: fn(),
|
openHomePage: fn(),
|
||||||
openSecurity: fn(),
|
openSecurity: fn(),
|
||||||
openSettings: fn(),
|
openSettings: fn(),
|
||||||
|
clearStatus: fn(),
|
||||||
});
|
});
|
||||||
return <UserMenuView vm={vm} />;
|
return <UserMenuView vm={vm} />;
|
||||||
};
|
};
|
||||||
@@ -67,6 +68,21 @@ export const LongerName: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rules needed for any story where the menu is open, for the reasons
|
||||||
|
// given in each rule.
|
||||||
|
const MENU_OPEN_A11Y_RULES = [
|
||||||
|
{
|
||||||
|
// Menu contains a header which is invalid
|
||||||
|
id: "aria-required-children",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Menu pops open by default
|
||||||
|
id: "aria-hidden-focus",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const Open: Story = {
|
export const Open: Story = {
|
||||||
args: {
|
args: {
|
||||||
open: true,
|
open: true,
|
||||||
@@ -83,18 +99,7 @@ export const Open: Story = {
|
|||||||
* to learn more.
|
* to learn more.
|
||||||
*/
|
*/
|
||||||
config: {
|
config: {
|
||||||
rules: [
|
rules: MENU_OPEN_A11Y_RULES,
|
||||||
{
|
|
||||||
// Menu contains a header which is invalid
|
|
||||||
id: "aria-required-children",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Menu pops open by default
|
|
||||||
id: "aria-hidden-focus",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -150,18 +155,7 @@ export const GuestOpen: Story = {
|
|||||||
* to learn more.
|
* to learn more.
|
||||||
*/
|
*/
|
||||||
config: {
|
config: {
|
||||||
rules: [
|
rules: MENU_OPEN_A11Y_RULES,
|
||||||
{
|
|
||||||
// Menu contains a header which is invalid
|
|
||||||
id: "aria-required-children",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Menu pops open by default
|
|
||||||
id: "aria-hidden-focus",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -170,26 +164,28 @@ export const GuestOpen: Story = {
|
|||||||
tags: ["!dev", "!autodocs"],
|
tags: ["!dev", "!autodocs"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithStatusEmoji: Story = {
|
export const WithStatus: Story = {
|
||||||
args: {
|
args: {
|
||||||
statusEmoji: "🐹",
|
userStatus: {
|
||||||
|
emoji: "🐹",
|
||||||
|
text: "On the wheel",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AllOptions: Story = {
|
export const WithStatusOpen: Story = {
|
||||||
args: {
|
args: {
|
||||||
displayName: "Alice",
|
open: true,
|
||||||
userId: "@alice:example.org",
|
userStatus: {
|
||||||
manageAccountHref: "#",
|
emoji: "🐹",
|
||||||
showAvatar: true,
|
text: "On the wheel",
|
||||||
actions: {
|
},
|
||||||
createAccount: true,
|
},
|
||||||
signIn: true,
|
parameters: {
|
||||||
linkNewDevice: true,
|
a11y: {
|
||||||
openSecurity: true,
|
config: {
|
||||||
openHomePage: true,
|
rules: MENU_OPEN_A11Y_RULES,
|
||||||
openFeedback: true,
|
},
|
||||||
openSettings: true,
|
},
|
||||||
} satisfies Record<keyof UserMenuViewSnapshot["actions"], true>,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
|
|
||||||
import * as stories from "./UserMenu.stories.tsx";
|
import * as stories from "./UserMenu.stories.tsx";
|
||||||
|
|
||||||
const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatusEmoji } = composeStories(stories);
|
const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatus, WithStatusOpen } = composeStories(stories);
|
||||||
|
|
||||||
describe("UserMenu", () => {
|
describe("UserMenu", () => {
|
||||||
it("renders a button", async () => {
|
it("renders a button", async () => {
|
||||||
@@ -29,7 +29,7 @@ describe("UserMenu", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
it("renders the status emoji when set", async () => {
|
it("renders the status emoji when set", async () => {
|
||||||
const { container } = render(<WithStatusEmoji />);
|
const { container } = render(<WithStatus />);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
it("renders a menu", async () => {
|
it("renders a menu", async () => {
|
||||||
@@ -50,4 +50,8 @@ describe("UserMenu", () => {
|
|||||||
const { baseElement } = render(<NoAvatar />);
|
const { baseElement } = render(<NoAvatar />);
|
||||||
expect(baseElement).toMatchSnapshot();
|
expect(baseElement).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
it("renders an open menu with user status", async () => {
|
||||||
|
const { container } = render(<WithStatusOpen />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type JSX } from "react";
|
import React, { type JSX } from "react";
|
||||||
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
import { Avatar, Button, IconButton, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
ChatProblemIcon,
|
ChatProblemIcon,
|
||||||
DevicesIcon,
|
DevicesIcon,
|
||||||
@@ -14,12 +14,15 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
PopOutIcon,
|
PopOutIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
CloseIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import styles from "./UserMenu.module.css";
|
import styles from "./UserMenu.module.css";
|
||||||
import { useViewModel, type ViewModel } from "../../core/viewmodel";
|
import { useViewModel, type ViewModel } from "../../core/viewmodel";
|
||||||
import { useI18n } from "../../core/i18n/i18nContext";
|
import { useI18n } from "../../core/i18n/i18nContext";
|
||||||
|
import { type UserStatus } from "../../core/userStatus";
|
||||||
|
import { _t } from "../..";
|
||||||
|
|
||||||
export interface UserMenuViewSnapshot {
|
export interface UserMenuViewSnapshot {
|
||||||
/**
|
/**
|
||||||
@@ -51,9 +54,9 @@ export interface UserMenuViewSnapshot {
|
|||||||
*/
|
*/
|
||||||
manageAccountHref?: string;
|
manageAccountHref?: string;
|
||||||
/**
|
/**
|
||||||
* The user status emoji to display, or undefined for no icon.
|
* The user status to display, or undefined for no icon / status.
|
||||||
*/
|
*/
|
||||||
statusEmoji?: string;
|
userStatus?: UserStatus;
|
||||||
/**
|
/**
|
||||||
* A set of actions that the user can perform from the menu.
|
* A set of actions that the user can perform from the menu.
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +104,10 @@ export declare interface UserMenuViewActions {
|
|||||||
* Called to open the settings dialog.
|
* Called to open the settings dialog.
|
||||||
*/
|
*/
|
||||||
openSettings: () => void;
|
openSettings: () => void;
|
||||||
|
/**
|
||||||
|
* Called when the user clicks the button to clear theirt status.
|
||||||
|
*/
|
||||||
|
clearStatus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserMenuViewProps = {
|
export type UserMenuViewProps = {
|
||||||
@@ -111,15 +118,40 @@ export type UserMenuViewProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function StatusButton({ status, clearStatus }: { status: UserStatus; clearStatus: () => void }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.statusButton}>
|
||||||
|
<Text as="span" className={styles.menuStatusEmoji}>
|
||||||
|
{status.emoji}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" className={styles.menuStatusText}>
|
||||||
|
{status.text}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
onClick={clearStatus}
|
||||||
|
aria-label={_t("menus|user_menu|clear_status")}
|
||||||
|
tooltip={_t("menus|user_menu|clear_status")}
|
||||||
|
size="28px"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
|
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
|
||||||
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, statusEmoji } =
|
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, userStatus } =
|
||||||
useViewModel(vm);
|
useViewModel(vm);
|
||||||
const { translate: _t } = useI18n();
|
const { translate: _t } = useI18n();
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
|
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
|
||||||
<div className={styles.avatarWrapper}>
|
<div className={styles.avatarWrapper}>
|
||||||
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
|
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
|
||||||
{statusEmoji && <span className={styles.statusEmoji}>{statusEmoji}</span>}
|
{userStatus && (
|
||||||
|
<Text as="div" className={styles.iconStatusEmoji}>
|
||||||
|
{userStatus.emoji}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -143,6 +175,7 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element
|
|||||||
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
|
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
|
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
|
||||||
<Text data-testid="userId" size="md" as="span" type="body">
|
<Text data-testid="userId" size="md" as="span" type="body">
|
||||||
{userId}
|
{userId}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -845,6 +845,62 @@ exports[`UserMenu > renders an open menu 1`] = `
|
|||||||
</body>
|
</body>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`UserMenu > renders an open menu with user status 1`] = `
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="UserMenu-module_wrapper"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-controls="radix-react-use-id-1"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="User menu"
|
||||||
|
class="UserMenu-module_triggerButton"
|
||||||
|
data-state="open"
|
||||||
|
id="radix-react-use-id-2"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="UserMenu-module_avatarWrapper"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-label="@person-name:homeserver.com"
|
||||||
|
class="_avatar_va14e_8"
|
||||||
|
data-color="1"
|
||||||
|
data-type="round"
|
||||||
|
role="img"
|
||||||
|
style="--cpd-avatar-size: 36px;"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
class="_image_va14e_43"
|
||||||
|
data-type="round"
|
||||||
|
height="36px"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
src="/static/element.png"
|
||||||
|
width="36px"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
|
||||||
|
>
|
||||||
|
🐹
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 UserMenu-module_displayName"
|
||||||
|
>
|
||||||
|
Sally Sanderson
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`UserMenu > renders condensed view 1`] = `
|
exports[`UserMenu > renders condensed view 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -923,11 +979,11 @@ exports[`UserMenu > renders the status emoji when set 1`] = `
|
|||||||
width="36px"
|
width="36px"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<div
|
||||||
class="UserMenu-module_statusEmoji"
|
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
|
||||||
>
|
>
|
||||||
🐹
|
🐹
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
|
|||||||
Reference in New Issue
Block a user