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:
@@ -8,11 +8,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { useEffect, useState } from "react";
|
||||
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
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 { useTypedEventEmitter } from "./useEventEmitter";
|
||||
import { useFeatureEnabled } from "./useSettings";
|
||||
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
|
||||
import { validateUserStatus } from "../utils/userStatus";
|
||||
|
||||
const logger = rootLogger.getChild("useUserStatus");
|
||||
|
||||
@@ -32,9 +33,8 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
|
||||
if (syncedUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
if (syncProfile["org.matrix.msc4426.status"]) {
|
||||
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
|
||||
}
|
||||
|
||||
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
|
||||
});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import SettingsStore from "../settings/SettingsStore";
|
||||
import { reject, success } from "./utils";
|
||||
import { UserFriendlyError } from "../languageHandler";
|
||||
import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import { userStatusTextWithinMaxLength } from "../utils/userStatus";
|
||||
import { setUserStatus, userStatusTextWithinMaxLength } from "../utils/userStatus";
|
||||
|
||||
export const statusCommand = new Command({
|
||||
command: "status",
|
||||
@@ -40,7 +40,7 @@ export const statusCommand = new Command({
|
||||
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
|
||||
}
|
||||
return success(
|
||||
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
|
||||
setUserStatus(cli, {
|
||||
emoji: emoji.segment,
|
||||
text,
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ClientEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { throttle } from "lodash";
|
||||
import { type UserStatus } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type ActionPayload } from "../dispatcher/payloads";
|
||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||
@@ -24,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { _t } from "../languageHandler";
|
||||
import { mediaFromMxc } from "../customisations/Media";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
|
||||
import { validateUserStatus } from "../utils/userStatus";
|
||||
|
||||
interface IState {
|
||||
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.
|
||||
*/
|
||||
|
||||
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 {
|
||||
emoji: string;
|
||||
text: string;
|
||||
}
|
||||
// MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
|
||||
// so we truncate anything longer than that.
|
||||
const MAX_STATUS_TEXT_BYTES = 256;
|
||||
|
||||
export function userStatusTextWithinMaxLength(text: string): boolean {
|
||||
const textEncoder = new TextEncoder();
|
||||
@@ -34,3 +34,14 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
|
||||
: `${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 { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
@@ -18,6 +19,7 @@ import { shouldShowFeedback } from "../../utils/Feedback";
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { clearUserStatus } from "../../utils/userStatus";
|
||||
|
||||
// Matches maximum size of an avatar in the UserMenu
|
||||
const AVATAR_PX = 88;
|
||||
@@ -42,7 +44,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
||||
expanded: !isPanelCollapsed,
|
||||
manageAccountHref: accountManagementEndpoint,
|
||||
showAvatar: isAuthenticated,
|
||||
statusEmoji: OwnProfileStore.instance.userStatus?.emoji,
|
||||
userStatus: OwnProfileStore.instance.userStatus,
|
||||
actions: {
|
||||
createAccount: !isAuthenticated,
|
||||
signIn: !isAuthenticated,
|
||||
@@ -57,7 +59,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
||||
|
||||
public constructor(
|
||||
private readonly dispatcher: MatrixDispatcher,
|
||||
client: MatrixClient,
|
||||
private readonly client: MatrixClient,
|
||||
isPanelCollapsed: boolean,
|
||||
accountManagementEndpoint?: string,
|
||||
) {
|
||||
@@ -73,8 +75,8 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
||||
public readonly recalculateProfile = (): void => {
|
||||
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
|
||||
const statusEmoji = OwnProfileStore.instance.userStatus?.emoji;
|
||||
this.snapshot.merge({ displayName, avatarUrl, statusEmoji });
|
||||
const userStatus = OwnProfileStore.instance.userStatus;
|
||||
this.snapshot.merge({ displayName, avatarUrl, userStatus });
|
||||
};
|
||||
|
||||
public readonly setOpen = (isOpen: boolean): void => {
|
||||
@@ -128,4 +130,11 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
|
||||
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 DisambiguatedProfileViewSnapshot,
|
||||
type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface,
|
||||
type UserStatus,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MouseEvent } from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
|
||||
import UserIdentifier from "../../../../customisations/UserIdentifier";
|
||||
import { type UserStatus } from "../../../../utils/userStatus";
|
||||
|
||||
/**
|
||||
* Information about a member for disambiguation purposes.
|
||||
|
||||
@@ -16,7 +16,6 @@ describe("/status", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function run(args?: string) {
|
||||
|
||||
@@ -360,6 +360,7 @@ export function createTestClient(): MatrixClient {
|
||||
deleteRoomTag: jest.fn().mockResolvedValue({}),
|
||||
setRoomTag: jest.fn().mockResolvedValue({}),
|
||||
getExtendedProfileProperty: jest.fn(),
|
||||
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="_wrapper_11z86_8 mx_UserMenu"
|
||||
class="_wrapper_bh5eg_8 mx_UserMenu"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="_triggerButton_11z86_80"
|
||||
class="_triggerButton_bh5eg_107"
|
||||
data-state="closed"
|
||||
id="radix-react-use-id-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_avatarWrapper_11z86_57"
|
||||
class="_avatarWrapper_bh5eg_57"
|
||||
>
|
||||
<span
|
||||
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(),
|
||||
...mockClientMethodsServer(),
|
||||
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
|
||||
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
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 () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
|
||||
@@ -17,8 +17,8 @@ exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
|
||||
"manageAccountHref": undefined,
|
||||
"open": true,
|
||||
"showAvatar": false,
|
||||
"statusEmoji": undefined,
|
||||
"userId": "@alice:domain",
|
||||
"userStatus": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -39,7 +39,7 @@ exports[`UserMenuViewModel should generate a menu options for a logged in client
|
||||
"manageAccountHref": undefined,
|
||||
"open": true,
|
||||
"showAvatar": true,
|
||||
"statusEmoji": undefined,
|
||||
"userId": "@alice:domain",
|
||||
"userStatus": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user