feat(call): add docked call panel and remote cursor overlay
- Implemented DockedCallPanel component to render the docked call interface when an active call is present. - Created RemoteCursorOverlay component to display remote cursor positions during screen sharing. - Added hooks for managing docked call state and remote cursor functionality. - Introduced pending drag state management for seamless transitions between docked and floating states. - Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility. - Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation. - Added state management for tracking joining progress of rooms in spaces.
This commit is contained in:
247
src/app/hooks/useAutoJoinSpaceRooms.ts
Normal file
247
src/app/hooks/useAutoJoinSpaceRooms.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Membership } from '../../types/matrix/room';
|
||||
import { isSpace } from '../utils/room';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import {
|
||||
setRoomNotificationPreference,
|
||||
RoomNotificationMode,
|
||||
} from './useRoomsNotificationPreferences';
|
||||
import { updateJoiningProgressAtom } from '../state/joiningProgress';
|
||||
|
||||
const PER_PAGE_COUNT = 100;
|
||||
const MAX_PAGES = 50;
|
||||
const JOIN_DELAY_MS = 500;
|
||||
|
||||
/** Join rules that allow automatic joining */
|
||||
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
|
||||
|
||||
/**
|
||||
* Extracts the server name from a Matrix room ID
|
||||
* @param roomId - The room ID (e.g., "!abc123:matrix.org")
|
||||
* @returns The server name (e.g., "matrix.org")
|
||||
*/
|
||||
function getServerFromRoomId(roomId: string): string {
|
||||
const parts = roomId.split(':');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a room/space can be automatically joined based on its join rule
|
||||
* @param hierarchyRoom - The room info from the space hierarchy
|
||||
* @returns Whether the room/space should be auto-joined
|
||||
*/
|
||||
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
const { join_rule: joinRule } = hierarchyRoom;
|
||||
|
||||
// Only join public or restricted rooms/spaces
|
||||
// Restricted ones are joinable if you're a member of the parent space
|
||||
if (!joinRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return AUTO_JOINABLE_RULES.includes(joinRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hierarchy item is a space
|
||||
* @param hierarchyRoom - The room info from the space hierarchy
|
||||
* @returns Whether the item is a space
|
||||
*/
|
||||
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
return hierarchyRoom.room_type === 'm.space';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all rooms from a space hierarchy
|
||||
* @param mx - Matrix client
|
||||
* @param spaceId - The space room ID
|
||||
* @returns Array of hierarchy rooms
|
||||
*/
|
||||
async function fetchSpaceHierarchy(
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): Promise<IHierarchyRoom[]> {
|
||||
const allRooms: IHierarchyRoom[] = [];
|
||||
let nextBatch: string | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
while (pageCount < MAX_PAGES) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await mx.getRoomHierarchy(spaceId, PER_PAGE_COUNT, undefined, false, nextBatch);
|
||||
allRooms.push(...result.rooms);
|
||||
nextBatch = result.next_batch;
|
||||
pageCount += 1;
|
||||
|
||||
if (!nextBatch) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
* @param ms - Milliseconds to delay
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** Callback for reporting join progress */
|
||||
type ProgressCallback = (current: number, total: number) => void;
|
||||
|
||||
/**
|
||||
* Attempts to join rooms/spaces with rate limiting and sets notification to Mentions & Keywords for rooms
|
||||
* @param mx - Matrix client
|
||||
* @param rooms - Array of hierarchy rooms to join
|
||||
* @param spaceId - The parent space ID (for via servers)
|
||||
* @param onProgress - Callback to report progress
|
||||
*/
|
||||
async function joinRoomsSequentially(
|
||||
mx: MatrixClient,
|
||||
rooms: IHierarchyRoom[],
|
||||
spaceId: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<void> {
|
||||
const spaceServer = getServerFromRoomId(spaceId);
|
||||
|
||||
const itemsToJoin = rooms.filter((room) => {
|
||||
// Check if already joined
|
||||
const existingRoom = mx.getRoom(room.room_id);
|
||||
if (existingRoom?.getMyMembership() === Membership.Join) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if can auto-join
|
||||
return canAutoJoin(room);
|
||||
});
|
||||
|
||||
// Separate spaces and rooms - join spaces first (rooms may depend on space membership)
|
||||
const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
|
||||
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
|
||||
|
||||
const total = itemsToJoin.length;
|
||||
let current = 0;
|
||||
|
||||
// Report initial progress
|
||||
if (total > 0) {
|
||||
onProgress?.(current, total);
|
||||
}
|
||||
|
||||
// Process item (space or room)
|
||||
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
|
||||
try {
|
||||
const itemServer = getServerFromRoomId(item.room_id);
|
||||
const viaServers = [itemServer, spaceServer].filter(Boolean);
|
||||
|
||||
await mx.joinRoom(item.room_id, { viaServers });
|
||||
|
||||
// Only set notification preferences for rooms, not spaces
|
||||
if (!isItemSpace) {
|
||||
await setRoomNotificationPreference(
|
||||
mx,
|
||||
item.room_id,
|
||||
RoomNotificationMode.SpecialMessages,
|
||||
RoomNotificationMode.Unset
|
||||
);
|
||||
}
|
||||
|
||||
await delay(JOIN_DELAY_MS);
|
||||
} catch {
|
||||
// Continue with other items even if one fails
|
||||
} finally {
|
||||
current += 1;
|
||||
onProgress?.(current, total);
|
||||
}
|
||||
};
|
||||
|
||||
// Join spaces first, then rooms
|
||||
await spacesToJoin.reduce(
|
||||
(promise, space) => promise.then(() => joinItem(space, true)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
await roomsToJoin.reduce(
|
||||
(promise, room) => promise.then(() => joinItem(room, false)),
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that automatically joins all accessible rooms when joining a space
|
||||
* @param mx - Matrix client instance
|
||||
*/
|
||||
export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
|
||||
const processingSpaces = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSpaceJoin = useCallback(
|
||||
async (room: Room) => {
|
||||
if (!settings.autoJoinSpaceRooms) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process spaces
|
||||
if (!isSpace(room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process when joining (not invites or leaves)
|
||||
if (room.getMyMembership() !== Membership.Join) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceId = room.roomId;
|
||||
|
||||
// Prevent duplicate processing
|
||||
if (processingSpaces.current.has(spaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processingSpaces.current.add(spaceId);
|
||||
|
||||
try {
|
||||
// Fetch the space hierarchy
|
||||
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
|
||||
|
||||
// Filter out the space itself
|
||||
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
|
||||
|
||||
// Join all accessible rooms with progress tracking
|
||||
await joinRoomsSequentially(mx, childRooms, spaceId, (current, total) => {
|
||||
setJoiningProgress({ spaceId, progress: { current, total } });
|
||||
});
|
||||
} finally {
|
||||
processingSpaces.current.delete(spaceId);
|
||||
// Clear progress when done
|
||||
setJoiningProgress({ spaceId, progress: null });
|
||||
}
|
||||
},
|
||||
[mx, settings.autoJoinSpaceRooms, setJoiningProgress]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMembershipChange = (room: Room, membership: string) => {
|
||||
if (membership === Membership.Join) {
|
||||
handleSpaceJoin(room);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.MyMembership, onMembershipChange);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.MyMembership, onMembershipChange);
|
||||
};
|
||||
}, [mx, handleSpaceJoin]);
|
||||
}
|
||||
175
src/app/hooks/useRoomEmbedFilters.ts
Normal file
175
src/app/hooks/useRoomEmbedFilters.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
/**
|
||||
* The room account data type for storing personal embed filter patterns.
|
||||
*/
|
||||
export const EMBED_FILTERS_ACCOUNT_DATA_TYPE = 'im.paarrot.embed_filters';
|
||||
|
||||
/**
|
||||
* Content structure for embed filter data.
|
||||
*/
|
||||
export interface EmbedFiltersContent {
|
||||
/**
|
||||
* Array of regex pattern strings to match URLs that should not show embeds.
|
||||
* Each pattern will be tested against URLs using RegExp.test().
|
||||
*/
|
||||
disabledPatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty embed filters content.
|
||||
*/
|
||||
const DEFAULT_EMBED_FILTERS: EmbedFiltersContent = {
|
||||
disabledPatterns: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the personal embed filters content from room account data.
|
||||
* @param room - The Matrix room to get filters for
|
||||
* @returns The embed filters content
|
||||
*/
|
||||
const getPersonalEmbedFiltersFromRoom = (room: Room): EmbedFiltersContent => {
|
||||
const accountDataEvent = room.getAccountData(EMBED_FILTERS_ACCOUNT_DATA_TYPE);
|
||||
if (!accountDataEvent) {
|
||||
return DEFAULT_EMBED_FILTERS;
|
||||
}
|
||||
|
||||
const content = accountDataEvent.getContent() as Partial<EmbedFiltersContent>;
|
||||
return {
|
||||
disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get and set personal embed filter patterns for a room.
|
||||
* These patterns are stored in room account data (per-user, per-room).
|
||||
* @param room - The Matrix room
|
||||
* @returns A tuple of [filters, setFilters]
|
||||
*/
|
||||
export const useRoomEmbedFilters = (
|
||||
room: Room
|
||||
): [EmbedFiltersContent, (filters: EmbedFiltersContent) => Promise<void>] => {
|
||||
const mx = useMatrixClient();
|
||||
const [filters, setFiltersState] = useState<EmbedFiltersContent>(() =>
|
||||
getPersonalEmbedFiltersFromRoom(room)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (event) => {
|
||||
if (event.getType() === EMBED_FILTERS_ACCOUNT_DATA_TYPE) {
|
||||
setFiltersState(getPersonalEmbedFiltersFromRoom(room));
|
||||
}
|
||||
};
|
||||
|
||||
room.on(RoomEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
const setFilters = useCallback(
|
||||
async (newFilters: EmbedFiltersContent): Promise<void> => {
|
||||
await mx.setRoomAccountData(room.roomId, EMBED_FILTERS_ACCOUNT_DATA_TYPE, newFilters);
|
||||
},
|
||||
[mx, room.roomId]
|
||||
);
|
||||
|
||||
return [filters, setFilters];
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get room-wide embed filter patterns set by room admins.
|
||||
* These patterns are stored as room state and apply to all users.
|
||||
* @param room - The Matrix room
|
||||
* @returns The room-wide embed filters content
|
||||
*/
|
||||
export const useRoomWideEmbedFilters = (room: Room): EmbedFiltersContent => {
|
||||
const stateEvent = useStateEvent(room, StateEvent.RoomEmbedFilters);
|
||||
if (!stateEvent) {
|
||||
return DEFAULT_EMBED_FILTERS;
|
||||
}
|
||||
|
||||
const content = stateEvent.getContent() as Partial<EmbedFiltersContent>;
|
||||
return {
|
||||
disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to set room-wide embed filter patterns.
|
||||
* Requires appropriate power level to send state events.
|
||||
* @param room - The Matrix room
|
||||
* @returns Function to save room-wide filters
|
||||
*/
|
||||
export const useSetRoomWideEmbedFilters = (
|
||||
room: Room
|
||||
): ((filters: EmbedFiltersContent) => Promise<void>) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const setFilters = useCallback(
|
||||
async (newFilters: EmbedFiltersContent): Promise<void> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomEmbedFilters as any, newFilters);
|
||||
},
|
||||
[mx, room.roomId]
|
||||
);
|
||||
|
||||
return setFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines personal and room-wide embed filter patterns.
|
||||
* @param personalFilters - Personal (per-user) filters
|
||||
* @param roomWideFilters - Room-wide (admin-set) filters
|
||||
* @returns Combined unique patterns
|
||||
*/
|
||||
export const combineEmbedFilters = (
|
||||
personalFilters: EmbedFiltersContent,
|
||||
roomWideFilters: EmbedFiltersContent
|
||||
): string[] => {
|
||||
const combined = new Set<string>([
|
||||
...personalFilters.disabledPatterns,
|
||||
...roomWideFilters.disabledPatterns,
|
||||
]);
|
||||
return Array.from(combined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests if a URL should have its embed disabled based on the filter patterns.
|
||||
* @param url - The URL to test
|
||||
* @param disabledPatterns - Array of regex pattern strings
|
||||
* @returns True if the URL matches any disabled pattern
|
||||
*/
|
||||
export const isUrlEmbedDisabled = (url: string, disabledPatterns: string[]): boolean => {
|
||||
if (!disabledPatterns || disabledPatterns.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return disabledPatterns.some((pattern) => {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(url);
|
||||
} catch {
|
||||
// Invalid regex pattern, skip it
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters an array of URLs, removing those that match any disabled pattern.
|
||||
* @param urls - Array of URLs to filter
|
||||
* @param disabledPatterns - Array of regex pattern strings
|
||||
* @returns Array of URLs that don't match any disabled pattern
|
||||
*/
|
||||
export const filterDisabledUrls = (urls: string[], disabledPatterns: string[]): string[] => {
|
||||
if (!disabledPatterns || disabledPatterns.length === 0) {
|
||||
return urls;
|
||||
}
|
||||
|
||||
return urls.filter((url) => !isUrlEmbedDisabled(url, disabledPatterns));
|
||||
};
|
||||
@@ -101,7 +101,6 @@ export const useRoomNotificationPreference = (
|
||||
|
||||
export const getRoomNotificationModeIcon = (mode?: RoomNotificationMode): IconSrc => {
|
||||
if (mode === RoomNotificationMode.Mute) return Icons.BellMute;
|
||||
if (mode === RoomNotificationMode.SpecialMessages) return Icons.BellPing;
|
||||
if (mode === RoomNotificationMode.AllMessages) return Icons.BellRing;
|
||||
|
||||
return Icons.Bell;
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SidebarItems = Array<TSidebarItem>;
|
||||
export type InCinnySpacesContent = {
|
||||
shortcut?: string[];
|
||||
sidebar?: SidebarItems;
|
||||
hidden?: string[];
|
||||
};
|
||||
|
||||
export const parseSidebar = (
|
||||
@@ -25,12 +26,14 @@ export const parseSidebar = (
|
||||
content?: InCinnySpacesContent
|
||||
) => {
|
||||
const sidebar = content?.sidebar ?? content?.shortcut ?? [];
|
||||
const hiddenSet = new Set(content?.hidden ?? []);
|
||||
const orphans = new Set(orphanSpaces);
|
||||
|
||||
const items: SidebarItems = [];
|
||||
|
||||
const safeToAdd = (spaceId: string): boolean => {
|
||||
if (typeof spaceId !== 'string') return false;
|
||||
if (hiddenSet.has(spaceId)) return false;
|
||||
const space = mx.getRoom(spaceId);
|
||||
if (space?.getMyMembership() !== Membership.Join) return false;
|
||||
return isSpace(space);
|
||||
@@ -52,17 +55,39 @@ export const parseSidebar = (
|
||||
) {
|
||||
const safeContent = item.content.filter(safeToAdd);
|
||||
safeContent.forEach((i) => orphans.delete(i));
|
||||
items.push({
|
||||
...item,
|
||||
content: Array.from(new Set(safeContent)),
|
||||
});
|
||||
if (safeContent.length > 0) {
|
||||
items.push({
|
||||
...item,
|
||||
content: Array.from(new Set(safeContent)),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
orphans.forEach((spaceId) => items.push(spaceId));
|
||||
orphans.forEach((spaceId) => {
|
||||
if (!hiddenSet.has(spaceId)) {
|
||||
items.push(spaceId);
|
||||
}
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse hidden spaces from account data
|
||||
*/
|
||||
export const parseHiddenSpaces = (
|
||||
mx: MatrixClient,
|
||||
content?: InCinnySpacesContent
|
||||
): string[] => {
|
||||
const hidden = content?.hidden ?? [];
|
||||
return hidden.filter((spaceId) => {
|
||||
if (typeof spaceId !== 'string') return false;
|
||||
const space = mx.getRoom(spaceId);
|
||||
if (space?.getMyMembership() !== Membership.Join) return false;
|
||||
return isSpace(space);
|
||||
});
|
||||
};
|
||||
|
||||
export const useSidebarItems = (
|
||||
orphanSpaces: string[]
|
||||
): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
|
||||
@@ -136,3 +161,80 @@ export const makeCinnySpacesContent = (
|
||||
|
||||
return newSpacesContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get and manage hidden spaces
|
||||
*/
|
||||
export const useHiddenSpaces = (): [string[], Dispatch<SetStateAction<string[]>>] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [hiddenSpaces, setHiddenSpaces] = useState(() => {
|
||||
const content = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
return parseHiddenSpaces(mx, content);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const content = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
setHiddenSpaces(parseHiddenSpaces(mx, content));
|
||||
}, [mx]);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
|
||||
const newContent = mEvent.getContent<InCinnySpacesContent>();
|
||||
setHiddenSpaces(parseHiddenSpaces(mx, newContent));
|
||||
}
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
);
|
||||
|
||||
return [hiddenSpaces, setHiddenSpaces];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create content for hiding a space
|
||||
*/
|
||||
export const makeHideSpaceContent = (
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): InCinnySpacesContent => {
|
||||
const currentContent =
|
||||
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
|
||||
|
||||
const currentHidden = currentContent.hidden ?? [];
|
||||
if (currentHidden.includes(spaceId)) {
|
||||
return currentContent;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentContent,
|
||||
hidden: [...currentHidden, spaceId],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create content for unhiding a space
|
||||
*/
|
||||
export const makeUnhideSpaceContent = (
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): InCinnySpacesContent => {
|
||||
const currentContent =
|
||||
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
|
||||
|
||||
const currentHidden = currentContent.hidden ?? [];
|
||||
|
||||
return {
|
||||
...currentContent,
|
||||
hidden: currentHidden.filter((id) => id !== spaceId),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user