URL navigation in interface and other improvements (#1633)

* load room on url change

* add direct room list

* render space room list

* fix css syntax error

* update scroll virtualizer

* render subspaces room list

* improve sidebar notification badge perf

* add nav category components

* add space recursive direct component

* use nav category component in home, direct and space room list

* add empty home and direct list layout

* fix unread room menu ref

* add more navigation items in room, direct and space tab

* add more navigation

* fix unread room menu to links

* fix space lobby and search link

* add explore navigation section

* add notifications navigation menu

* redirect to initial path after login

* include unsupported room in rooms

* move router hooks in hooks/router folder

* add featured explore - WIP

* load featured room with room summary

* fix room card topic line clamp

* add react query

* load room summary using react query

* add join button in room card

* add content component

* use content component in featured community content

* fix content width

* add responsive room card grid

* fix async callback error status

* add room card error button

* fix client drawer shrink

* add room topic viewer

* open room card topic in viewer

* fix room topic close btn

* add get orphan parent util

* add room card error dialog

* add view featured room or space btn

* refactor orphanParent to orphanParents

* WIP - explore server

* show space hint in room card

* add room type filters

* add per page item limit popout

* reset scroll on public rooms load

* refactor explore ui

* refactor public rooms component

* reset search on server change

* fix typo

* add empty featured section info

* display user server on top

* make server room card view btn clickable

* add user server as default redirect for explore path

* make home empty btn clickable

* add thirdparty instance filter in server explore

* remove since param on instance change

* add server button in explore menu

* rename notifications path to inbox

* update react-virtual

* Add notification messages inbox - WIP

* add scroll top container component

* add useInterval hook

* add visibility change callback prop to scroll top container component

* auto refresh notifications every 10 seconds

* make message related component reusable

* refactor matrix event renderer hoook

* render notification message content

* refactor matrix event renderer hook

* update sequence card styles

* move room navigate hook in global hooks

* add open message button in notifications

* add mark room as read button in notification group

* show error in notification messages

* add more featured spaces

* render reply in notification messages

* make notification message reply clickable

* add outline prop for attachments

* make old settings dialog viewable

* add open featured communities as default config option

* add invite count notification badge in sidebar and inbox menu

* add element size observer hook

* improve element size observer hook props

* improve screen size hook

* fix room avatar util function

* allow Text props in Time component

* fix dm room util function

* add invitations

* add no invites and notification cards

* fix inbox tab unread badge visible without invite count

* update folds and change inbox icon

* memo search param construction

* add message search in home

* fix default message search order

* fix display edited message new content

* highlight search text in search messages

* fix message search loading

* disable log in production

* add use space context

* add useRoom context

* fix space room list

* fix inbox tab active state

* add hook to get space child room recursive

* add search for space

* add virtual tile component

* virtualize home and directs room list

* update nav category component

* use virtual tile component in more places

* fix message highlight when click on reply twice

* virtualize space room list

* fix space room list lag issue

* update folds

* add room nav item component in space room list

* use room nav item in home and direct room list

* make space categories closable and save it in local storage

* show unread room when category is collapsed

* make home and direct room list category closable

* rename room nav item show avatar prop

* fix explore server category text alignment

* rename closedRoomCategories to closedNavCategories

* add nav category handler hook

* save and restore last navigation path on space select

* filter space rooms category by activity when it is closed

* save and restore home and direct nav path state

* save and restore inbox active path on open

* save and restore explore tab active path

* remove notification badge unread menu

* add join room or space before navigate screen

* move room component to features folder and add new room header

* update folds

* add room header menu

* fix home room list activity sorting

* do not hide selected room item on category closed in home and direct tab

* replace old select room/tab call with navigate hook

* improve state event hooks

* show room card summary for joined rooms

* prevent room from opening in wrong tab

* only show message sender id on hover in modern layout

* revert state event hooks changes

* add key prop to room provider components

* add welcome page

* prevent excessive redirects

* fix sidebar style with no spaces

* move room settings in popup window

* remove invite option from room settings

* fix open room list search

* add leave room prompt

* standardize room and user avatar

* fix avatar text size

* add new reply layout

* rename space hierarchy hook

* add room topic hook

* add room name hook

* add room avatar hook and add direct room avatar util

* space lobby - WIP

* hide invalid space child event from space hierarchy in lobby

* move lobby to features

* fix element size observer hook width and height

* add lobby header and hero section

* add hierarchy room item error and loading state

* add first and last child prop in sequence card

* redirect to lobby from index path

* memo and retry hierarchy room summary error

* fix hierarchy room item styles

* rename lobby hierarchy item card to room item card

* show direct room avatar in space lobby

* add hierarchy space item

* add space item unknown room join button

* fix space hierarchy hook refresh after new space join

* change user avatar color and fallback render to user icon

* change room avatar fallback to room icon

* rename room/user avatar renderInitial prop to renderFallback

* add room join and view button in space lobby

* make power level api more reusable

* fix space hierarchy not updating on child update

* add menu to suggest or remove space children

* show reply arrow in place of reply bend in message

* fix typeerror in search because of wrong js-sdk t.ds

* do not refetch hierarchy room summary on window focus

* make room/user avatar un-draggable

* change welcome page support button copy

* drag-and-drop ordering of lobby spaces/rooms - WIP

* add ASCIILexicalTable algorithms

* fix wrong power level check in lobby items options

* fix lobby can drop checks

* fix join button error crash

* fix reply spacing

* fix m direct updated with other account data

* add option to open room/space settings from lobby

* add option in lobby to add new or existing room/spaces

* fix room nav item selected styles

* add space children reorder mechanism

* fix space child reorder bug

* fix hierarchy item sort function

* Apply reorder of lobby into room list

* add and improve space lobby menu items

* add existing spaces menu in lobby

* change restricted room allow params when dragging outside space

* move featured servers config from homeserver list

* removed unused features from space settings

* add canonical alias as name fallback in lobby item

* fix unreliable unread count update bug

* fix after login redirect

* fix room card topic hover style

* Add dnd and folders in sidebar spaces

* fix orphan space not visible in sidebar

* fix sso login has mix of icon and button

* fix space children not  visible in home upon leaving space

* recalculate notification on updating any space child

* fix user color saturation/lightness

* add user color to user avatar

* add background colors to room avatar

* show 2 length initial in sidebar space avatar

* improve link color

* add nav button component

* open legacy create room and create direct

* improve page route structure

* handle hash router in path utils

* mobile friendly router and navigation

* make room header member drawer icon mobile friendly

* setup index redirect for inbox and explore server route

* add leave space prompt

* improve member drawer filter menu

* add space context menu

* add context menu in home

* add leave button in lobby items

* render user tab avatar on sidebar

* force overwrite netlify - test

* netlify test

* fix reset-password path without server redirected to login

* add message link copy button in message menu

* reset unread on sync prepared

* fix stuck typing notifications

* show typing indication in room nav item

* refactor closedNavCategories atom to use userId in store key

* refactor closedLobbyCategoriesAtom to include userId in store key

* refactor navToActivePathAtom to use userId in storage key

* remove unused file

* refactor openedSidebarFolderAtom to include userId in storage key

* add context menu for sidebar space tab

* fix eslint not working

* add option to pin/unpin child spaces

* add context menu for directs tab

* add context menu for direct and home tab

* show lock icon for non-public space in header

* increase matrix max listener count

* wrap lobby add space room in callback hook
This commit is contained in:
Ajay Bura 2024-05-31 19:49:46 +05:30 committed by GitHub
parent 2b7d825694
commit 4c76a7fd18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
290 changed files with 17447 additions and 3224 deletions

View file

@ -0,0 +1,22 @@
import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => {
const directMatch = useMatch({
path: getDirectPath(),
caseSensitive: true,
end: false,
});
return !!directMatch;
};
export const useDirectCreateSelected = (): boolean => {
const match = useMatch({
path: getDirectCreatePath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -0,0 +1,28 @@
import { useMatch, useParams } from 'react-router-dom';
import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
export const useExploreSelected = (): boolean => {
const match = useMatch({
path: getExplorePath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useExploreFeaturedSelected = (): boolean => {
const match = useMatch({
path: getExploreFeaturedPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useExploreServer = (): string | undefined => {
const { server } = useParams();
return server;
};

View file

@ -0,0 +1,47 @@
import { useMatch } from 'react-router-dom';
import {
getHomeCreatePath,
getHomeJoinPath,
getHomePath,
getHomeSearchPath,
} from '../../pages/pathUtils';
export const useHomeSelected = (): boolean => {
const homeMatch = useMatch({
path: getHomePath(),
caseSensitive: true,
end: false,
});
return !!homeMatch;
};
export const useHomeCreateSelected = (): boolean => {
const match = useMatch({
path: getHomeCreatePath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useHomeJoinSelected = (): boolean => {
const match = useMatch({
path: getHomeJoinPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useHomeSearchSelected = (): boolean => {
const match = useMatch({
path: getHomeSearchPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -0,0 +1,36 @@
import { useMatch } from 'react-router-dom';
import {
getInboxInvitesPath,
getInboxNotificationsPath,
getInboxPath,
} from '../../pages/pathUtils';
export const useInboxSelected = (): boolean => {
const match = useMatch({
path: getInboxPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useInboxNotificationsSelected = (): boolean => {
const match = useMatch({
path: getInboxNotificationsPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useInboxInvitesSelected = (): boolean => {
const match = useMatch({
path: getInboxInvitesPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -0,0 +1,15 @@
import { useParams } from 'react-router-dom';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
import { useMatrixClient } from '../useMatrixClient';
export const useSelectedRoom = (): string | undefined => {
const mx = useMatrixClient();
const { roomIdOrAlias } = useParams();
const roomId =
roomIdOrAlias && isRoomAlias(roomIdOrAlias)
? getCanonicalAliasRoomId(mx, roomIdOrAlias)
: roomIdOrAlias;
return roomId;
};

View file

@ -0,0 +1,37 @@
import { useMatch, useParams } from 'react-router-dom';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
import { useMatrixClient } from '../useMatrixClient';
import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
export const useSelectedSpace = (): string | undefined => {
const mx = useMatrixClient();
const { spaceIdOrAlias } = useParams();
const spaceId =
spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
? getCanonicalAliasRoomId(mx, spaceIdOrAlias)
: spaceIdOrAlias;
return spaceId;
};
export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => {
const match = useMatch({
path: getSpaceLobbyPath(spaceIdOrAlias),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => {
const match = useMatch({
path: getSpaceSearchPath(spaceIdOrAlias),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -0,0 +1,14 @@
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useEffect } from 'react';
export const useAccountDataCallback = (
mx: MatrixClient,
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]
) => {
useEffect(() => {
mx.on(ClientEvent.AccountData, onAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, onAccountData);
};
}, [mx, onAccountData]);
};

View file

@ -68,9 +68,11 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
throw new Error('AsyncCallbackHook: Request replaced!');
}
if (alive()) {
setState({
status: AsyncStatus.Success,
data,
queueMicrotask(() => {
setState({
status: AsyncStatus.Success,
data,
});
});
}
return data;
@ -78,10 +80,13 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
if (currentReqNumber !== reqNumberRef.current) {
throw new Error('AsyncCallbackHook: Request replaced!');
}
if (alive()) {
setState({
status: AsyncStatus.Error,
error: e as TError,
queueMicrotask(() => {
setState({
status: AsyncStatus.Error,
error: e as TError,
});
});
}
throw e;

View file

@ -0,0 +1,12 @@
import { Capabilities } from 'matrix-js-sdk';
import { createContext, useContext } from 'react';
const CapabilitiesContext = createContext<Capabilities | null>(null);
export const CapabilitiesProvider = CapabilitiesContext.Provider;
export function useCapabilities(): Capabilities {
const capabilities = useContext(CapabilitiesContext);
if (!capabilities) throw new Error('Capabilities are not provided!');
return capabilities;
}

View file

@ -0,0 +1,27 @@
import { MouseEventHandler } from 'react';
type CategoryAction =
| {
type: 'PUT';
categoryId: string;
}
| {
type: 'DELETE';
categoryId: string;
};
export const useCategoryHandler = (
setAtom: (action: CategoryAction) => void,
closed: (categoryId: string) => boolean
) => {
const handleCategoryClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const categoryId = evt.currentTarget.getAttribute('data-category-id');
if (!categoryId) return;
if (closed(categoryId)) {
setAtom({ type: 'DELETE', categoryId });
return;
}
setAtom({ type: 'PUT', categoryId });
};
return handleCategoryClick;
};

View file

@ -1,14 +1,23 @@
import { createContext, useContext } from 'react';
export type HashRouterConfig = {
enabled?: boolean;
basename?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
allowCustomHomeservers?: boolean;
hashRouter?: {
enabled?: boolean;
basename?: string;
featuredCommunities?: {
openAsDefault?: boolean;
spaces?: string[];
rooms?: string[];
servers?: string[];
};
hashRouter?: HashRouterConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -1,9 +1,9 @@
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { hasDMWith, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
import { selectRoom } from '../../client/action/navigation';
import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
import { hasDevices } from '../../util/matrixUtil';
import * as roomActions from '../../client/action/room';
import { useRoomNavigate } from './useRoomNavigate';
export const SHRUG = '¯\\_(ツ)_/¯';
@ -59,6 +59,8 @@ export type CommandContent = {
export type CommandRecord = Record<Command, CommandContent>;
export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
const { navigateRoom } = useRoomNavigate();
const commands: CommandRecord = useMemo(
() => ({
[Command.Me]: {
@ -84,16 +86,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
if (userIds.length === 0) return;
if (userIds.length === 1) {
const dmRoomId = hasDMWith(mx, userIds[0]);
const dmRoomId = getDMRoomFor(mx, userIds[0])?.roomId;
if (dmRoomId) {
selectRoom(dmRoomId);
navigateRoom(dmRoomId);
return;
}
}
const devices = await Promise.all(userIds.map(hasDevices));
const isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(userIds, isEncrypt);
selectRoom(result.room_id);
navigateRoom(result.room_id);
},
},
[Command.Join]: {
@ -212,7 +214,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
},
}),
[mx, room]
[mx, room, navigateRoom]
);
return commands;

View file

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
export const useElementSizeObserver = <T extends Element>(
element: () => T | null,
onResize: (width: number, height: number, element: T) => void
) => {
useResizeObserver(
useCallback(
(entries) => {
const target = element();
if (!target) return;
const targetEntry = getResizeObserverEntry(target, entries);
if (targetEntry) {
const { clientWidth, clientHeight } = targetEntry.target;
onResize(clientWidth, clientHeight, target);
}
},
[element, onResize]
),
element
);
};

View file

@ -0,0 +1,24 @@
import { useEffect, useMemo } from 'react';
export type IntervalCallback = () => void;
/**
* @param callback interval callback.
* @param ms interval time in milliseconds. negative value will stop the interval.
* @returns interval id or undefined if not running.
*/
export const useInterval = (callback: IntervalCallback, ms: number): number | undefined => {
const id = useMemo(() => {
if (ms < 0) return undefined;
return window.setInterval(callback, ms);
}, [callback, ms]);
useEffect(
() => () => {
window.clearInterval(id);
},
[id]
);
return id;
};

View file

@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix';
export const useJoinedRoomId = (allRooms: string[], roomIdOrAlias: string): string | undefined => {
const mx = useMatrixClient();
const joinedRoomId = useMemo(() => {
const roomId = isRoomAlias(roomIdOrAlias)
? getCanonicalAliasRoomId(mx, roomIdOrAlias)
: roomIdOrAlias;
if (roomId && allRooms.includes(roomId)) return roomId;
return undefined;
}, [mx, allRooms, roomIdOrAlias]);
return joinedRoomId;
};

View file

@ -0,0 +1,44 @@
import { GuestAccess, HistoryVisibility, JoinRule, Room } from 'matrix-js-sdk';
import { getStateEvent } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
export type LocalRoomSummary = {
roomId: string;
name: string;
topic?: string;
avatarUrl?: string;
canonicalAlias?: string;
worldReadable?: boolean;
guestCanJoin?: boolean;
memberCount?: number;
roomType?: string;
joinRule?: JoinRule;
};
export const useLocalRoomSummary = (room: Room): LocalRoomSummary => {
const topicEvent = getStateEvent(room, StateEvent.RoomTopic);
const topicContent = topicEvent?.getContent();
const topic =
topicContent && typeof topicContent.topic === 'string' ? topicContent.topic : undefined;
const historyEvent = getStateEvent(room, StateEvent.RoomHistoryVisibility);
const historyContent = historyEvent?.getContent();
const worldReadable =
historyContent && typeof historyContent.history_visibility === 'string'
? historyContent.history_visibility === HistoryVisibility.WorldReadable
: undefined;
const guestCanJoin = room.getGuestAccess() === GuestAccess.CanJoin;
return {
roomId: room.roomId,
name: room.name,
topic,
avatarUrl: room.getMxcAvatarUrl() ?? undefined,
canonicalAlias: room.getCanonicalAlias() ?? undefined,
worldReadable,
guestCanJoin,
memberCount: room.getJoinedMemberCount(),
roomType: room.getType(),
joinRule: room.getJoinRule(),
};
};

View file

@ -1,80 +1,31 @@
import { ReactNode } from 'react';
import { MatrixEvent } from 'matrix-js-sdk';
import { MessageEvent, StateEvent } from '../../types/matrix/room';
export type EventRenderer<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export type EventRenderer<T extends unknown[]> = (...args: T) => ReactNode;
export type EventRendererOpts<T extends unknown[]> = {
renderRoomMessage?: EventRenderer<T>;
renderRoomEncrypted?: EventRenderer<T>;
renderSticker?: EventRenderer<T>;
renderRoomMember?: EventRenderer<T>;
renderRoomName?: EventRenderer<T>;
renderRoomTopic?: EventRenderer<T>;
renderRoomAvatar?: EventRenderer<T>;
renderStateEvent?: EventRenderer<T>;
renderEvent?: EventRenderer<T>;
};
export type EventRendererOpts<T extends unknown[]> = Record<string, EventRenderer<T>>;
export type RenderMatrixEvent<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
eventType: string,
isStateEvent: boolean,
...args: T
) => ReactNode;
export const useMatrixEventRenderer =
<T extends unknown[]>({
renderRoomMessage,
renderRoomEncrypted,
renderSticker,
renderRoomMember,
renderRoomName,
renderRoomTopic,
renderRoomAvatar,
renderStateEvent,
renderEvent,
}: EventRendererOpts<T>): RenderMatrixEvent<T> =>
(eventId, mEvent, ...args) => {
const eventType = mEvent.getWireType();
<T extends unknown[]>(
typeToRenderer: EventRendererOpts<T>,
renderStateEvent?: EventRenderer<T>,
renderEvent?: EventRenderer<T>
): RenderMatrixEvent<T> =>
(eventType, isStateEvent, ...args) => {
const renderer = typeToRenderer[eventType];
if (typeToRenderer[eventType]) return renderer(...args);
if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
return renderRoomMessage(eventId, mEvent, ...args);
if (isStateEvent && renderStateEvent) {
return renderStateEvent(...args);
}
if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
return renderRoomEncrypted(eventId, mEvent, ...args);
}
if (eventType === MessageEvent.Sticker && renderSticker) {
return renderSticker(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomMember && renderRoomMember) {
return renderRoomMember(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomName && renderRoomName) {
return renderRoomName(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
return renderRoomTopic(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
return renderRoomAvatar(eventId, mEvent, ...args);
}
if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
return renderStateEvent(eventId, mEvent, ...args);
}
if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
return renderEvent(eventId, mEvent, ...args);
if (!isStateEvent && renderEvent) {
return renderEvent(...args);
}
return null;
};

View file

@ -0,0 +1,16 @@
import { createContext, useContext } from 'react';
export interface MediaConfig {
[key: string]: unknown;
'm.upload.size'?: number;
}
const MediaConfigContext = createContext<MediaConfig | null>(null);
export const MediaConfigProvider = MediaConfigContext.Provider;
export function useMediaConfig(): MediaConfig {
const mediaConfig = useContext(MediaConfigContext);
if (!mediaConfig) throw new Error('Media configs are not provided!');
return mediaConfig;
}

View file

@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { useLocation } from 'react-router-dom';
import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
export const useNavToActivePathMapper = (navId: string) => {
const location = useLocation();
const setNavToActivePath = useSetAtom(useNavToActivePathAtom());
useEffect(() => {
const { pathname, search, hash } = location;
setNavToActivePath({
type: 'PUT',
navId,
path: { pathname, search, hash },
});
}, [location, setNavToActivePath, navId]);
};

View file

@ -1,7 +1,11 @@
import { Room } from 'matrix-js-sdk';
import { createContext, useCallback, useContext } from 'react';
import { createContext, useCallback, useContext, useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { useForceUpdate } from './useForceUpdate';
import { useStateEventCallback } from './useStateEventCallback';
import { useMatrixClient } from './useMatrixClient';
import { getStateEvent } from '../utils/room';
export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
@ -16,7 +20,7 @@ enum DefaultPowerLevels {
historical = 0,
}
interface IPowerLevels {
export interface IPowerLevels {
users_default?: number;
state_default?: number;
events_default?: number;
@ -31,9 +35,75 @@ interface IPowerLevels {
notifications?: Record<string, number>;
}
export type GetPowerLevel = (userId: string) => number;
export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
export function usePowerLevels(room: Room): IPowerLevels {
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels: IPowerLevels =
powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
return powerLevels;
}
export const PowerLevelsContext = createContext<IPowerLevels | null>(null);
export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
export const usePowerLevelsContext = (): IPowerLevels => {
const pl = useContext(PowerLevelsContext);
if (!pl) throw new Error('PowerLevelContext is not initialized!');
return pl;
};
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
const mx = useMatrixClient();
const [updateCount, forceUpdate] = useForceUpdate();
useStateEventCallback(
mx,
useCallback(
(event) => {
const roomId = event.getRoomId();
if (
roomId &&
event.getType() === StateEvent.RoomPowerLevels &&
event.getStateKey() === '' &&
rooms.find((r) => r.roomId === roomId)
) {
forceUpdate();
}
},
[rooms, forceUpdate]
)
);
const roomToPowerLevels = useMemo(
() => {
const rToPl = new Map<string, IPowerLevels>();
rooms.forEach((room) => {
const pl = getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>();
if (pl) rToPl.set(room.roomId, pl);
});
return rToPl;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[rooms, updateCount]
);
return roomToPowerLevels;
};
export type GetPowerLevel = (powerLevels: IPowerLevels, userId: string | undefined) => number;
export type CanSend = (
powerLevels: IPowerLevels,
eventType: string | undefined,
powerLevel: number
) => boolean;
export type CanDoAction = (
powerLevels: IPowerLevels,
action: PowerLevelActions,
powerLevel: number
) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;
@ -42,51 +112,58 @@ export type PowerLevelsAPI = {
canDoAction: CanDoAction;
};
export function usePowerLevels(room: Room): PowerLevelsAPI {
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
export const powerLevelAPI: PowerLevelsAPI = {
getPowerLevel: (powerLevels, userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (userId && users && typeof users[userId] === 'number') {
return users[userId];
}
return usersDefault ?? DefaultPowerLevels.usersDefault;
},
canSendEvent: (powerLevels, eventType, powerLevel) => {
const { events, events_default: eventsDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
}
return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
},
canSendStateEvent: (powerLevels, eventType, powerLevel) => {
const { events, state_default: stateDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
}
return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
},
canDoAction: (powerLevels, action, powerLevel) => {
const requiredPL = powerLevels[action];
if (typeof requiredPL === 'number') {
return powerLevel >= requiredPL;
}
return powerLevel >= DefaultPowerLevels[action];
},
};
const getPowerLevel: GetPowerLevel = useCallback(
(userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (users && typeof users[userId] === 'number') {
return users[userId];
}
return usersDefault ?? DefaultPowerLevels.usersDefault;
},
export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
const getPowerLevel = useCallback(
(userId: string | undefined) => powerLevelAPI.getPowerLevel(powerLevels, userId),
[powerLevels]
);
const canSendEvent: CanSend = useCallback(
(eventType, powerLevel) => {
const { events, events_default: eventsDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
}
return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
},
const canSendEvent = useCallback(
(eventType: string | undefined, powerLevel: number) =>
powerLevelAPI.canSendEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
const canSendStateEvent: CanSend = useCallback(
(eventType, powerLevel) => {
const { events, state_default: stateDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
}
return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
},
const canSendStateEvent = useCallback(
(eventType: string | undefined, powerLevel: number) =>
powerLevelAPI.canSendStateEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
const canDoAction: CanDoAction = useCallback(
(action, powerLevel) => {
const requiredPL = powerLevels[action];
if (typeof requiredPL === 'number') {
return powerLevel >= requiredPL;
}
return powerLevel >= DefaultPowerLevels[action];
},
const canDoAction = useCallback(
(action: PowerLevelActions, powerLevel: number) =>
powerLevelAPI.canDoAction(powerLevels, action, powerLevel),
[powerLevels]
);
@ -96,14 +173,4 @@ export function usePowerLevels(room: Room): PowerLevelsAPI {
canSendStateEvent,
canDoAction,
};
}
export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
export const usePowerLevelsAPI = (): PowerLevelsAPI => {
const api = useContext(PowerLevelsContext);
if (!api) throw new Error('PowerLevelContext is not initialized!');
return api;
};

12
src/app/hooks/useRoom.ts Normal file
View file

@ -0,0 +1,12 @@
import { Room } from 'matrix-js-sdk';
import { createContext, useContext } from 'react';
const RoomContext = createContext<Room | null>(null);
export const RoomProvider = RoomContext.Provider;
export function useRoom(): Room {
const room = useContext(RoomContext);
if (!room) throw new Error('Room not provided!');
return room;
}

View file

@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
if (dm) {
return room.getAvatarFallbackMember()?.getMxcAvatarUrl();
}
const content = avatarEvent?.getContent();
const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined;
return avatarMxc;
};
export const useRoomName = (room: Room): string => {
const [name, setName] = useState(room.name);
useEffect(() => {
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
setName(room.name);
};
room.on(RoomEvent.Name, handleRoomNameChange);
return () => {
room.removeListener(RoomEvent.Name, handleRoomNameChange);
};
}, [room]);
return name;
};
export const useRoomTopic = (room: Room): string | undefined => {
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
const content = topicEvent?.getContent();
const topic = content && typeof content.topic === 'string' ? content.topic : undefined;
return topic;
};

View file

@ -1,68 +0,0 @@
import { ReactNode } from 'react';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
export type MsgContentRenderer<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export type RoomMsgContentRendererOpts<T extends unknown[]> = {
renderText?: MsgContentRenderer<T>;
renderEmote?: MsgContentRenderer<T>;
renderNotice?: MsgContentRenderer<T>;
renderImage?: MsgContentRenderer<T>;
renderVideo?: MsgContentRenderer<T>;
renderAudio?: MsgContentRenderer<T>;
renderFile?: MsgContentRenderer<T>;
renderLocation?: MsgContentRenderer<T>;
renderBadEncrypted?: MsgContentRenderer<T>;
renderUnsupported?: MsgContentRenderer<T>;
renderBrokenFallback?: MsgContentRenderer<T>;
};
export type RenderRoomMsgContent<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export const useRoomMsgContentRenderer =
<T extends unknown[]>({
renderText,
renderEmote,
renderNotice,
renderImage,
renderVideo,
renderAudio,
renderFile,
renderLocation,
renderBadEncrypted,
renderUnsupported,
renderBrokenFallback,
}: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
(eventId, mEvent, ...args) => {
const msgType = mEvent.getContent().msgtype;
let node: ReactNode = null;
if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
else if (msgType === MsgType.Notice && renderNotice)
node = renderNotice(eventId, mEvent, ...args);
else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
else if (msgType === MsgType.Location && renderLocation)
node = renderLocation(eventId, mEvent, ...args);
else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
node = renderBadEncrypted(eventId, mEvent, ...args);
else if (renderUnsupported) {
node = renderUnsupported(eventId, mEvent, ...args);
}
if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
return node;
};

View file

@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
getDirectRoomPath,
getHomeRoomPath,
getSpacePath,
getSpaceRoomPath,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const navigateSpace = useCallback(
(roomId: string) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
navigate(getSpacePath(roomIdOrAlias));
},
[mx, navigate]
);
const navigateRoom = useCallback(
(roomId: string, eventId?: string) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, orphanParents[0]);
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId));
return;
}
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId));
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId));
},
[mx, navigate, roomToParents, mDirects]
);
return {
navigateSpace,
navigateRoom,
};
};

View file

@ -0,0 +1,10 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
export const useRoomTypingMember = (roomId: string) => {
const typing = useAtomValue(
useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
);
return typing;
};

View file

@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
import { createContext, useCallback, useContext, useState } from 'react';
import { useElementSizeObserver } from './useElementSizeObserver';
export const TABLET_BREAKPOINT = 1124;
export const MOBILE_BREAKPOINT = 750;
@ -16,21 +16,24 @@ export const getScreenSize = (width: number): ScreenSize => {
return ScreenSize.Mobile;
};
export const useScreenSize = (): [ScreenSize, number] => {
const [size, setSize] = useState<[ScreenSize, number]>([
getScreenSize(document.body.clientWidth),
document.body.clientWidth,
]);
useResizeObserver(
useCallback((entries) => {
const bodyEntry = getResizeObserverEntry(document.body, entries);
if (bodyEntry) {
const bWidth = bodyEntry.contentRect.width;
setSize([getScreenSize(bWidth), bWidth]);
}
}, []),
document.body
export const useScreenSize = (): ScreenSize => {
const [size, setSize] = useState<ScreenSize>(getScreenSize(document.body.clientWidth));
useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setSize(getScreenSize(width)), [])
);
return size;
};
const ScreenSizeContext = createContext<ScreenSize | null>(null);
export const ScreenSizeProvider = ScreenSizeContext.Provider;
export const useScreenSizeContext = (): ScreenSize => {
const screenSize = useContext(ScreenSizeContext);
if (screenSize === null) {
throw new Error('Screen size not provided!');
}
return screenSize;
};

View file

@ -0,0 +1,138 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData, isSpace } from '../utils/room';
import { Membership } from '../../types/matrix/room';
import { useAccountDataCallback } from './useAccountDataCallback';
export type ISidebarFolder = {
name?: string;
id: string;
content: string[];
};
export type TSidebarItem = string | ISidebarFolder;
export type SidebarItems = Array<TSidebarItem>;
export type InCinnySpacesContent = {
shortcut?: string[];
sidebar?: SidebarItems;
};
export const parseSidebar = (
mx: MatrixClient,
orphanSpaces: string[],
content?: InCinnySpacesContent
) => {
const sidebar = content?.sidebar ?? content?.shortcut ?? [];
const orphans = new Set(orphanSpaces);
const items: SidebarItems = [];
const safeToAdd = (spaceId: string): boolean => {
if (typeof spaceId !== 'string') return false;
const space = mx.getRoom(spaceId);
if (space?.getMyMembership() !== Membership.Join) return false;
return isSpace(space);
};
sidebar.forEach((item) => {
if (typeof item === 'string') {
if (safeToAdd(item) && !items.includes(item)) {
orphans.delete(item);
items.push(item);
}
return;
}
if (
typeof item === 'object' &&
typeof item.id === 'string' &&
Array.isArray(item.content) &&
!items.find((i) => (typeof i === 'string' ? false : i.id === item.id))
) {
const safeContent = item.content.filter(safeToAdd);
safeContent.forEach((i) => orphans.delete(i));
items.push({
...item,
content: Array.from(new Set(safeContent)),
});
}
});
orphans.forEach((spaceId) => items.push(spaceId));
return items;
};
export const useSidebarItems = (
orphanSpaces: string[]
): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
const mx = useMatrixClient();
const [sidebarItems, setSidebarItems] = useState(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
});
useEffect(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
}, [mx, orphanSpaces]);
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
const newContent = mEvent.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, newContent));
}
},
[mx, orphanSpaces]
)
);
return [sidebarItems, setSidebarItems];
};
export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
const newItems: SidebarItems = items
.map((item) => {
if (typeof item === 'string') {
if (item === roomId) return null;
return item;
}
if (item.content.includes(roomId)) {
const newContent = item.content.filter((id) => id !== roomId);
if (newContent.length === 0) return null;
return {
...item,
content: newContent,
};
}
return item;
})
.filter((item) => item !== null) as SidebarItems;
return newItems;
};
export const makeCinnySpacesContent = (
mx: MatrixClient,
items: SidebarItems
): InCinnySpacesContent => {
const currentInSpaces =
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
const newSpacesContent: InCinnySpacesContent = {
...currentInSpaces,
sidebar: items,
};
return newSpacesContent;
};

17
src/app/hooks/useSpace.ts Normal file
View file

@ -0,0 +1,17 @@
import { Room } from 'matrix-js-sdk';
import { createContext, useContext } from 'react';
const SpaceContext = createContext<Room | null>(null);
export const SpaceProvider = SpaceContext.Provider;
export function useSpace(): Room {
const space = useContext(SpaceContext);
if (!space) throw new Error('Space not provided!');
return space;
}
export function useSpaceOptionally(): Room | null {
const space = useContext(SpaceContext);
return space;
}

View file

@ -0,0 +1,253 @@
import { atom, useAtom, useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
import { isRoomId } from '../utils/matrix';
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
import { useStateEventCallback } from './useStateEventCallback';
export type HierarchyItem =
| {
roomId: string;
content: MSpaceChildContent;
ts: number;
space: true;
parentId?: string;
}
| {
roomId: string;
content: MSpaceChildContent;
ts: number;
space?: false;
parentId: string;
};
type GetRoomCallback = (roomId: string) => Room | undefined;
const hierarchyItemTs: SortFunc<HierarchyItem> = (a, b) => byTsOldToNew(a.ts, b.ts);
const hierarchyItemByOrder: SortFunc<HierarchyItem> = (a, b) =>
byOrderKey(a.content.order, b.content.order);
const getHierarchySpaces = (
rootSpaceId: string,
getRoom: GetRoomCallback,
spaceRooms: Set<string>
): HierarchyItem[] => {
const rootSpaceItem: HierarchyItem = {
roomId: rootSpaceId,
content: { via: [] },
ts: 0,
space: true,
};
let spaceItems: HierarchyItem[] = [];
const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => {
if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
const space = getRoom(spaceItem.roomId);
spaceItems.push(spaceItem);
if (!space) return;
const childEvents = getStateEvents(space, StateEvent.SpaceChild);
childEvents.forEach((childEvent) => {
if (!isValidChild(childEvent)) return;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return;
// because we can not find if a childId is space without joining
// or requesting room summary, we will look it into spaceRooms local
// cache which we maintain as we load summary in UI.
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
const childItem: HierarchyItem = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
space: true,
parentId: spaceItem.roomId,
};
findAndCollectHierarchySpaces(childItem);
}
});
};
findAndCollectHierarchySpaces(rootSpaceItem);
spaceItems = [
rootSpaceItem,
...spaceItems
.filter((item) => item.roomId !== rootSpaceId)
.sort(hierarchyItemTs)
.sort(hierarchyItemByOrder),
];
return spaceItems;
};
const getSpaceHierarchy = (
rootSpaceId: string,
spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean
): HierarchyItem[] => {
const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
const space = getRoom(spaceItem.roomId);
if (!space || closedCategory(spaceItem.roomId)) {
return [spaceItem];
}
const childEvents = getStateEvents(space, StateEvent.SpaceChild);
const childItems: HierarchyItem[] = [];
childEvents.forEach((childEvent) => {
if (!isValidChild(childEvent)) return;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return;
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
const childItem: HierarchyItem = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
parentId: spaceItem.roomId,
};
childItems.push(childItem);
});
return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
});
return hierarchy;
};
export const useSpaceHierarchy = (
spaceId: string,
spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean
): HierarchyItem[] => {
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const [hierarchyAtom] = useState(() =>
atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory))
);
const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
useEffect(() => {
setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
}, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory]);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() !== StateEvent.SpaceChild) return;
const eventRoomId = mEvent.getRoomId();
if (!eventRoomId) return;
if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
}
},
[spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory]
)
);
return hierarchy;
};
const getSpaceJoinedHierarchy = (
rootSpaceId: string,
getRoom: GetRoomCallback,
excludeRoom: (parentId: string, roomId: string) => boolean,
sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
): HierarchyItem[] => {
const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
const space = getRoom(spaceItem.roomId);
if (!space) {
return [];
}
const joinedRoomEvents = getStateEvents(space, StateEvent.SpaceChild).filter((childEvent) => {
if (!isValidChild(childEvent)) return false;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return false;
const room = getRoom(childId);
if (!room || room.isSpaceRoom()) return false;
return true;
});
if (joinedRoomEvents.length === 0) return [];
const childItems: HierarchyItem[] = [];
joinedRoomEvents.forEach((childEvent) => {
const childId = childEvent.getStateKey();
if (!childId) return;
if (excludeRoom(space.roomId, childId)) return;
const childItem: HierarchyItem = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
parentId: spaceItem.roomId,
};
childItems.push(childItem);
});
return [spaceItem, ...sortRoomItems(spaceItem.roomId, childItems)];
});
return hierarchy;
};
export const useSpaceJoinedHierarchy = (
spaceId: string,
getRoom: GetRoomCallback,
excludeRoom: (parentId: string, roomId: string) => boolean,
sortByActivity: (spaceId: string) => boolean
): HierarchyItem[] => {
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const sortRoomItems = useCallback(
(sId: string, items: HierarchyItem[]) => {
if (sortByActivity(sId)) {
items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId));
return items;
}
items.sort(hierarchyItemTs).sort(hierarchyItemByOrder);
return items;
},
[mx, sortByActivity]
);
const [hierarchyAtom] = useState(() =>
atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems))
);
const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
useEffect(() => {
setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
}, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() !== StateEvent.SpaceChild) return;
const eventRoomId = mEvent.getRoomId();
if (!eventRoomId) return;
if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
}
},
[spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems]
)
);
return hierarchy;
};

View file

@ -0,0 +1,14 @@
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useEffect } from 'react';
export const useSyncState = (
mx: MatrixClient,
onChange: ClientEventHandlerMap[ClientEvent.Sync]
): void => {
useEffect(() => {
mx.on(ClientEvent.Sync, onChange);
return () => {
mx.removeListener(ClientEvent.Sync, onChange);
};
}, [mx, onChange]);
};

View file

@ -1,10 +1,9 @@
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo, useRef } from 'react';
import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
type TypingStatusUpdater = (typing: boolean) => void;
const TYPING_TIMEOUT_MS = 5000; // 5 seconds
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
const statusSentTsRef = useRef<number>(0);