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:
2026-02-19 17:49:48 +11:00
parent bfbdf98468
commit 3f6f2134ad
42 changed files with 3801 additions and 219 deletions

View 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]);
}

View 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));
};

View File

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

View File

@@ -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),
};
};