Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions apps/web/src/hooks/useUserStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
*/

import { useEffect, useState } from "react";
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
import { ClientEvent } 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 { validateUserStatus } from "../utils/userStatus";
import { fetchUserStatus, validateUserStatus } from "../utils/userStatus";

const logger = rootLogger.getChild("useUserStatus");

Expand All @@ -27,43 +27,39 @@
export function useUserStatus(userId: string | undefined): UserStatus | undefined {
const isEnabled = useFeatureEnabled("feature_user_status");
const matrixClient = useMatrixClientContext();
const [rawUserStatus, setRawUserStatus] = useState<unknown>();
const [userStatus, setUserStatus] = useState<UserStatus | undefined>();

useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
if (syncedUserId !== userId) {
return;
}

setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
setUserStatus(validateUserStatus(syncProfile["org.matrix.msc4426.status"]));
});
useEffect(() => {
(async () => {
if (!isEnabled) {
return;
}
if (!userId) {
setRawUserStatus(undefined);
setUserStatus(undefined);

Check warning on line 45 in apps/web/src/hooks/useUserStatus.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 45 is not covered by tests
return;
}
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
setRawUserStatus(undefined);
setUserStatus(undefined);
return;
}
try {
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
setRawUserStatus(result);
const result = await fetchUserStatus(matrixClient, userId);
setUserStatus(result);
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
setRawUserStatus(undefined);
} else {
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
})();
}, [isEnabled, userId, matrixClient]);
if (!isEnabled) {
return;
}

return validateUserStatus(rawUserStatus);
return userStatus;
}
51 changes: 49 additions & 2 deletions apps/web/src/utils/userStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,32 @@ Please see LICENSE files in the repository root for full details.
*/

import { type UserStatus } from "@element-hq/web-shared-components";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

// 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;

// Static Intl.Segmenter for grabbing the first grapheme of a user status emoji.
// We make one and keep it for performance.
const intlSegmenter = new Intl.Segmenter();

/**
* Checks if a given text is within the maximum allowed length for a user status.
* @param text The text to check.
* @returns True if the text is within the maximum length, false otherwise.
*/
export function userStatusTextWithinMaxLength(text: string): boolean {
const textEncoder = new TextEncoder();
return textEncoder.encode(text).length <= MAX_STATUS_TEXT_BYTES;
}

/**
* Validates an object from a user profile and returns a UserStatus object if it contains a valid user status.
* @param rawUserStatus The raw user status object to validate.
* @returns A UserStatus object if valid, otherwise undefined.
*/
export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefined {
if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
return undefined;
Expand All @@ -28,20 +43,52 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
return undefined;
}
return {
emoji: rawUserStatus.emoji,
emoji: [...intlSegmenter.segment(rawUserStatus.emoji)][0]?.segment,
text: userStatusTextWithinMaxLength(rawUserStatus.text)
? rawUserStatus.text
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
};
}

/**
* Fetch the MSC4426 user status of the given user. Returns undefined if the server does not
* support extended profiles, the user has no (valid) status, or the status could not be fetched.
*
* @param client The Matrix client to fetch the status with.
* @param userId The ID of the user whose status is being fetched.
*/
export async function fetchUserStatus(client: MatrixClient, userId: string): Promise<UserStatus | undefined> {
if ((await client.doesServerSupportExtendedProfiles()) === false) {
return undefined;
}
try {
return validateUserStatus(await client.getExtendedProfileProperty(userId, "org.matrix.msc4426.status"));
} catch (ex) {
if (!(ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND")) {
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
return undefined;
}
}

/**
* Sets the MSC4426 user status for the given user.
*
* @param client The Matrix client to use.
* @param userStatus The user status to set.
*/
export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", {
emoji: userStatus.emoji,
text: userStatus.text,
});
}

/**
* Clears the MSC4426 user status for the given user.
*
* @param client The Matrix client to use.
*/
export function clearUserStatus(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
}
47 changes: 45 additions & 2 deletions apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
type RoomListItemViewSnapshot,
type RoomListItemViewActions,
type Section,
type UserStatus,
} from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { ClientEvent, RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";

import type { Room, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import type { Room, MatrixClient, RoomMember, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
Expand All @@ -41,6 +43,7 @@
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import { _t } from "../../languageHandler";
import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus";

/**
* View section type without `isSelected` field
Expand Down Expand Up @@ -75,6 +78,11 @@
*/
private availableSections: Sections;

/**
* The user ID of the other user if this room is a DM, used to show their user status.
*/
private readonly dmUserId?: string;

public constructor(props: RoomItemProps) {
// Get notification state first so we can generate a complete initial snapshot
const notifState = RoomNotificationStateStore.instance.getRoomState(props.room);
Expand Down Expand Up @@ -141,6 +149,17 @@

// Load message preview asynchronously (sync data is already complete)
void this.loadAndSetMessagePreview();

// If this room is a DM, show the MSC4426 user status of the other user
this.dmUserId = DMRoomMap.shared().getUserIdForRoomId(props.room.roomId) ?? undefined;
if (this.dmUserId) {
props.client.on(ClientEvent.UserProfileUpdate, this.onUserProfileUpdate);
this.disposables.track(() => {
props.client.off(ClientEvent.UserProfileUpdate, this.onUserProfileUpdate);
});

this.updateUserStatus();
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I debated whether it was worth abstracting this away into a class that just gets and emits updates for a single user's status (like the useUserStatus hook but for view models). I'm not sure it is, partially as we don't have this as a pattern afaik, but it feels like a thing that might come up more often.

}

public dispose(): void {
Expand Down Expand Up @@ -268,6 +287,30 @@
this.snapshot.merge({ messagePreview });
}

/**
* Handler for profile updates received via sync, to keep the DM user's status up to date.
*/
private onUserProfileUpdate: ClientEventHandlerMap[ClientEvent.UserProfileUpdate] = (userId, profile) => {

Check warning on line 293 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onUserProfileUpdate' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9CJ5-Iw_HdArncb_1k&open=AZ9CJ5-Iw_HdArncb_1k&pullRequest=34191
if (userId !== this.dmUserId || !SettingsStore.getValue("feature_user_status")) return;
this.snapshot.merge({ userStatus: validateUserStatus(profile?.["org.matrix.msc4426.status"]) });

Check warning on line 295 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 294-295 are not covered by tests
};

/**
* Fetch and set the user status of the DM user, if the feature is enabled.
*/
private updateUserStatus(): void {
(async () => {
let userStatus: UserStatus | undefined;
if (this.dmUserId && SettingsStore.getValue("feature_user_status")) {
userStatus = await fetchUserStatus(this.props.client, this.dmUserId);

Check warning on line 305 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 305 is not covered by tests
}
if (this.disposables.isDisposed) return;
this.snapshot.merge({ userStatus });
})().catch((ex) => {
logger.warn(`Failed to update userStatus for ${this.dmUserId}`, ex);

Check warning on line 310 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 309-310 are not covered by tests
});
}

/**
* Generate a complete RoomListItem with all synchronous data.
* Message preview is loaded separately to avoid blocking initial render.
Expand Down
64 changes: 62 additions & 2 deletions apps/web/test/unit-tests/utils/userStatus-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ 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 { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";

import { clearUserStatus, setUserStatus, userStatusTextWithinMaxLength } from "../../../src/utils/userStatus";
import {
clearUserStatus,
fetchUserStatus,
setUserStatus,
userStatusTextWithinMaxLength,
} from "../../../src/utils/userStatus";
import { stubClient } from "../../test-utils";

describe("userStatus utils", () => {
Expand Down Expand Up @@ -39,6 +45,60 @@ describe("userStatus utils", () => {
});
});

describe("fetchUserStatus", () => {
let client: MatrixClient;

beforeEach(() => {
client = stubClient();
client.doesServerSupportExtendedProfiles = jest.fn();
});

it("returns undefined if the server does not support extended profiles", async () => {
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(false);

await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
expect(client.getExtendedProfileProperty).not.toHaveBeenCalled();
});

it("returns the validated status if the server supports extended profiles and has a status set", async () => {
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐳", text: "Feeling a little blue" });

await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(
"@alice:example.com",
"org.matrix.msc4426.status",
);
});

it("returns undefined if the status is invalid", async () => {
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
mocked(client.getExtendedProfileProperty).mockResolvedValue({ text: "Feeling a little blue" });

await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});

it("returns undefined if the user has no status set", async () => {
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
mocked(client.getExtendedProfileProperty).mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
);

await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});

it("returns undefined and logs a warning if fetching the status fails unexpectedly", async () => {
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
const error = new Error("network error");
mocked(client.getExtendedProfileProperty).mockRejectedValue(error);

await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
});

describe("clearUserStatus", () => {
let client: MatrixClient;

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions packages/shared-components/src/core/userStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Please see LICENSE files in the repository root for full details.
* The emoji should be a single grapheme cluster.
*/
export interface UserStatus {
/**
* The emoji representing the user's status. This must be a single grapheme cluster.
*/
emoji: string;
/**
* The text representing the user's status.
*/
text: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React, { type JSX, memo, type ReactNode } from "react";
import { Text } from "@vector-im/compound-web";
import { Text, Tooltip } from "@vector-im/compound-web";
import classNames from "classnames";

import { Flex } from "../../../../core/utils/Flex";
Expand Down Expand Up @@ -54,7 +54,15 @@
<div className={styles.ellipsis}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
{item.userStatus && (

Check warning on line 57 in packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 57 is not covered by tests
<Tooltip description={item.userStatus.text}>
<Text as="span" className={styles.userStatusEmoji}>
{item.userStatus.emoji}
</Text>
</Tooltip>
)}
</div>

{item.messagePreview && (
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
{item.messagePreview}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@
text-overflow: ellipsis;
}

.roomName {
/* Allow the room name to shrink and truncate when displayed next to the user status emoji */
min-width: 0;
}

.userStatusEmoji {
padding-left: var(--cpd-space-1-5x);
}

.selected {
.container {
background-color: var(--cpd-color-bg-action-tertiary-selected);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,12 @@ export const SectionDisabled: Story = {
areSectionsEnabled: false,
},
};

export const WithUserStatus: Story = {
args: {
userStatus: {
emoji: "🌭",
text: "Hot",
},
},
};
Loading
Loading