mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-10 09:15:16 +00:00
RoomList: improve performance (#32919)
* perf: add memo to room avatar view * perf: batch rlsV3 emit * perf: avoid to re-render the room list if the room list state and sections are same * perf: listen only message preview of the specific room * perf: avoid to re-render the room list item if the notification or the content is same * chore: replace useState and useEffect by useMemo in virtualized list * fix: listen to room name event in RoomAvatar * fix: room avatar re-render when room is low priority
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||||
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
|
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
|
||||||
import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
|||||||
const roomMember = useDmMember(room);
|
const roomMember = useDmMember(room);
|
||||||
const presence = usePresence(room, roomMember);
|
const presence = usePresence(room, roomMember);
|
||||||
const isPublic = useIsPublic(room);
|
const isPublic = useIsPublic(room);
|
||||||
const isLowPriority = !!room.tags[DefaultTagID.LowPriority];
|
const isLowPriority = useTypedEventEmitterState(room, RoomEvent.Tags, () => !!room.tags[DefaultTagID.LowPriority]);
|
||||||
|
|
||||||
let badgeDecoration: AvatarBadgeDecoration | undefined;
|
let badgeDecoration: AvatarBadgeDecoration | undefined;
|
||||||
if (isLowPriority) {
|
if (isLowPriority) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo, type ComponentProps } from "react";
|
import React, { useCallback, useMemo, type ComponentProps } from "react";
|
||||||
import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix";
|
import { type Room, RoomType, KnownMembership, EventType, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
|
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
|
||||||
|
|
||||||
import BaseAvatar from "./BaseAvatar";
|
import BaseAvatar from "./BaseAvatar";
|
||||||
@@ -21,6 +21,7 @@ import { useSettingValue } from "../../../hooks/useSettings";
|
|||||||
import { useRoomState } from "../../../hooks/useRoomState";
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
||||||
import { MediaPreviewValue } from "../../../@types/media_preview";
|
import { MediaPreviewValue } from "../../../@types/media_preview";
|
||||||
|
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||||
|
|
||||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
||||||
// Room may be left unset here, but if it is,
|
// Room may be left unset here, but if it is,
|
||||||
@@ -37,7 +38,8 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
|
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
|
||||||
const roomName = room?.name ?? oobData?.name ?? "?";
|
const name = useTypedEventEmitterState(room, RoomEvent.Name, () => room?.name);
|
||||||
|
const roomName = name ?? oobData?.name ?? "?";
|
||||||
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
|
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
|
||||||
const roomIdName = useRoomIdName(room, oobData);
|
const roomIdName = useRoomIdName(room, oobData);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type JSX } from "react";
|
import React, { memo, type JSX } from "react";
|
||||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||||
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||||
@@ -33,7 +33,7 @@ interface RoomAvatarViewProps {
|
|||||||
* Component to display the avatar of a room.
|
* Component to display the avatar of a room.
|
||||||
* Currently only 32px size is supported.
|
* Currently only 32px size is supported.
|
||||||
*/
|
*/
|
||||||
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
export const RoomAvatarView = memo(function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
||||||
const vm = useRoomAvatarViewModel(room);
|
const vm = useRoomAvatarViewModel(room);
|
||||||
// No decoration, we just show the avatar
|
// No decoration, we just show the avatar
|
||||||
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
|
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
|
||||||
@@ -54,7 +54,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
|||||||
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
|
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the decoration for the avatar based on the presence.
|
* Get the decoration for the avatar based on the presence.
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
private roomSkipList?: RoomSkipList;
|
private roomSkipList?: RoomSkipList;
|
||||||
private readonly msc3946ProcessDynamicPredecessor: boolean;
|
private readonly msc3946ProcessDynamicPredecessor: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a batched LISTS_UPDATE_EVENT emission is pending.
|
||||||
|
* Used by {@link scheduleEmit} to coalesce rapid-fire updates into a single emit per frame.
|
||||||
|
*/
|
||||||
|
private pendingEmit = false;
|
||||||
|
|
||||||
public constructor(dispatcher: MatrixDispatcher) {
|
public constructor(dispatcher: MatrixDispatcher) {
|
||||||
super(dispatcher);
|
super(dispatcher);
|
||||||
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
|
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
|
||||||
@@ -243,7 +249,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
newMembership === EffectiveMembership.Leave
|
newMembership === EffectiveMembership.Leave
|
||||||
) {
|
) {
|
||||||
this.roomSkipList.removeRoom(payload.room);
|
this.roomSkipList.removeRoom(payload.room);
|
||||||
this.emit(LISTS_UPDATE_EVENT);
|
this.scheduleEmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +275,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
case Action.AfterForgetRoom: {
|
case Action.AfterForgetRoom: {
|
||||||
const room = payload.room;
|
const room = payload.room;
|
||||||
this.roomSkipList.removeRoom(room);
|
this.roomSkipList.removeRoom(room);
|
||||||
this.emit(LISTS_UPDATE_EVENT);
|
this.scheduleEmit();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (needsEmit) this.emit(LISTS_UPDATE_EVENT);
|
if (needsEmit) this.scheduleEmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +355,20 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a batched emission of LISTS_UPDATE_EVENT using requestAnimationFrame.
|
||||||
|
* Multiple calls within the same frame are coalesced into a single emit.
|
||||||
|
*/
|
||||||
|
private scheduleEmit(): void {
|
||||||
|
if (!this.pendingEmit) {
|
||||||
|
this.pendingEmit = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.pendingEmit = false;
|
||||||
|
this.emit(LISTS_UPDATE_EVENT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a room to the skiplist and emit an update.
|
* Add a room to the skiplist and emit an update.
|
||||||
* @param room The room to add to the skiplist
|
* @param room The room to add to the skiplist
|
||||||
@@ -366,13 +387,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
} else {
|
} else {
|
||||||
this.roomSkipList.reInsertRoom(room);
|
this.roomSkipList.reInsertRoom(room);
|
||||||
}
|
}
|
||||||
this.emit(LISTS_UPDATE_EVENT);
|
this.scheduleEmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onActiveSpaceChanged(): void {
|
private onActiveSpaceChanged(): void {
|
||||||
if (!this.roomSkipList) return;
|
if (!this.roomSkipList) return;
|
||||||
this.roomSkipList.calculateActiveSpaceForNodes();
|
this.roomSkipList.calculateActiveSpaceForNodes();
|
||||||
this.emit(LISTS_UPDATE_EVENT);
|
this.scheduleEmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import type { RoomNotificationState } from "../../stores/notifications/RoomNotif
|
|||||||
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
|
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
|
||||||
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
||||||
import { MessagePreviewStore } from "../../stores/message-preview";
|
import { MessagePreviewStore } from "../../stores/message-preview";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
|
||||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||||
import DMRoomMap from "../../utils/DMRoomMap";
|
import DMRoomMap from "../../utils/DMRoomMap";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
@@ -32,6 +31,7 @@ import { UIComponent } from "../../settings/UIFeature";
|
|||||||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||||
import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications";
|
import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications";
|
||||||
import { tagRoom } from "../../utils/room/tagRoom";
|
import { tagRoom } from "../../utils/room/tagRoom";
|
||||||
|
import { keepIfSame } from "../../utils/keepIfSame";
|
||||||
import dispatcher from "../../dispatcher/dispatcher";
|
import dispatcher from "../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
@@ -69,8 +69,12 @@ export class RoomListItemViewModel
|
|||||||
// Subscribe to notification state changes for this room
|
// Subscribe to notification state changes for this room
|
||||||
this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged);
|
this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged);
|
||||||
|
|
||||||
// Subscribe to message preview changes (will filter to this room)
|
// Subscribe to message preview changes for this specific room
|
||||||
this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged);
|
this.disposables.trackListener(
|
||||||
|
MessagePreviewStore.instance,
|
||||||
|
MessagePreviewStore.getPreviewChangedEventName(props.room),
|
||||||
|
this.onMessagePreviewChanged,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to settings changes for message preview toggle
|
// Subscribe to settings changes for message preview toggle
|
||||||
const settingsWatchRef = SettingsStore.watchSetting(
|
const settingsWatchRef = SettingsStore.watchSetting(
|
||||||
@@ -163,8 +167,12 @@ export class RoomListItemViewModel
|
|||||||
*/
|
*/
|
||||||
private updateItem(): void {
|
private updateItem(): void {
|
||||||
const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState);
|
const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState);
|
||||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
this.snapshot.merge({
|
||||||
this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview });
|
...newItem,
|
||||||
|
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
||||||
|
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||||
|
messagePreview: this.snapshot.current.messagePreview,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMessagePreviewTag(): string {
|
private getMessagePreviewTag(): string {
|
||||||
|
|||||||
@@ -452,8 +452,8 @@ export class RoomListViewModel
|
|||||||
isLoadingRooms,
|
isLoadingRooms,
|
||||||
isRoomListEmpty,
|
isRoomListEmpty,
|
||||||
activeFilterId,
|
activeFilterId,
|
||||||
roomListState,
|
roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState),
|
||||||
sections,
|
sections: keepIfSame(this.snapshot.current.sections, sections),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -6,7 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { renderHook, waitFor } from "jest-matrix-react";
|
import { renderHook, waitFor } from "jest-matrix-react";
|
||||||
import { JoinRule, type MatrixClient, type Room, RoomMember, User } from "matrix-js-sdk/src/matrix";
|
import {
|
||||||
|
JoinRule,
|
||||||
|
type MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
type Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomMember,
|
||||||
|
User,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { act } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AvatarBadgeDecoration,
|
AvatarBadgeDecoration,
|
||||||
@@ -78,6 +87,7 @@ describe("RoomAvatarViewModel", () => {
|
|||||||
|
|
||||||
// 4. With presence, public room, video room and low priority, low priority takes precedence
|
// 4. With presence, public room, video room and low priority, low priority takes precedence
|
||||||
room.tags[DefaultTagID.LowPriority] = {};
|
room.tags[DefaultTagID.LowPriority] = {};
|
||||||
|
act(() => room.emit(RoomEvent.Tags, new MatrixEvent(), room));
|
||||||
rerender(room);
|
rerender(room);
|
||||||
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
|
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ describe("RoomListStoreV3", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.spyOn(global, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home);
|
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home);
|
||||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home);
|
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home);
|
||||||
jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve());
|
jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve());
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
|
|||||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||||
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
||||||
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
|
||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||||
@@ -200,7 +199,7 @@ describe("RoomListItemViewModel", () => {
|
|||||||
text: "Updated message",
|
text: "Updated message",
|
||||||
} as MessagePreview);
|
} as MessagePreview);
|
||||||
|
|
||||||
MessagePreviewStore.instance.emit(UPDATE_EVENT);
|
MessagePreviewStore.instance.emit(MessagePreviewStore.getPreviewChangedEventName(room));
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
|
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
|
||||||
|
|||||||
@@ -180,19 +180,12 @@ export function useVirtualizedList<Item, Context>(
|
|||||||
/** Range of currently visible items in the viewport */
|
/** Range of currently visible items in the viewport */
|
||||||
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||||
/** Map from item keys to their indices in the items array */
|
/** Map from item keys to their indices in the items array */
|
||||||
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
const keyToIndexMap = useMemo(() => {
|
||||||
/** Whether the list is currently focused */
|
const map = new Map<string, number>();
|
||||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
items.forEach((item, index) => map.set(getItemKey(item), index));
|
||||||
|
return map;
|
||||||
// Update the key-to-index mapping whenever items change
|
|
||||||
useEffect(() => {
|
|
||||||
const newKeyToIndexMap = new Map<string, number>();
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
const key = getItemKey(item);
|
|
||||||
newKeyToIndexMap.set(key, index);
|
|
||||||
});
|
|
||||||
setKeyToIndexMap(newKeyToIndexMap);
|
|
||||||
}, [items, getItemKey]);
|
}, [items, getItemKey]);
|
||||||
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
|
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user