From e9798a22c327cd8d1d8623226789053f513dc0bc Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Tue, 5 Aug 2025 18:35:18 +0530
Subject: [PATCH 04/39] Show file size exceeds error on upload (#2411)
* Show file size exceeds error on upload
* fix missing import and make size bold
---
.../upload-card/CompactUploadCardRenderer.tsx | 22 ++++++++++++++++---
.../upload-card/UploadCardRenderer.tsx | 22 ++++++++++++++++---
2 files changed, 38 insertions(+), 6 deletions(-)
diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx
index 998b517a..b9bada71 100644
--- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx
+++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx
@@ -4,7 +4,8 @@ import { UploadCard, UploadCardError, CompactUploadCardProgress } from './Upload
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
-import { getFileTypeIcon } from '../../utils/common';
+import { bytesToSize, getFileTypeIcon } from '../../utils/common';
+import { useMediaConfig } from '../../hooks/useMediaConfig';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@@ -19,10 +20,16 @@ export function CompactUploadCardRenderer({
onComplete,
}: CompactUploadCardRendererProps) {
const mx = useMatrixClient();
+ const mediaConfig = useMediaConfig();
+ const allowSize = mediaConfig['m.upload.size'] || Infinity;
+
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
+ const fileSizeExceeded = file.size >= allowSize;
- if (upload.status === UploadStatus.Idle) startUpload();
+ if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
+ startUpload();
+ }
const removeUpload = () => {
cancelUpload();
@@ -76,7 +83,7 @@ export function CompactUploadCardRenderer({
>
) : (
<>
- {upload.status === UploadStatus.Idle && (
+ {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
)}
{upload.status === UploadStatus.Loading && (
@@ -87,6 +94,15 @@ export function CompactUploadCardRenderer({
{upload.error.message}
)}
+ {upload.status === UploadStatus.Idle && fileSizeExceeded && (
+
+
+ The file size exceeds the limit. Maximum allowed size is{' '}
+ {bytesToSize(allowSize)}, but the uploaded file is{' '}
+ {bytesToSize(file.size)}.
+
+
+ )}
>
)}
diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx
index 4383e204..0a127a08 100644
--- a/src/app/components/upload-card/UploadCardRenderer.tsx
+++ b/src/app/components/upload-card/UploadCardRenderer.tsx
@@ -4,13 +4,14 @@ import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
-import { getFileTypeIcon } from '../../utils/common';
+import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import {
roomUploadAtomFamily,
TUploadItem,
TUploadMetadata,
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
+import { useMediaConfig } from '../../hooks/useMediaConfig';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
@@ -75,12 +76,18 @@ export function UploadCardRenderer({
onComplete,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
+ const mediaConfig = useMediaConfig();
+ const allowSize = mediaConfig['m.upload.size'] || Infinity;
+
const uploadAtom = roomUploadAtomFamily(fileItem.file);
const { metadata } = fileItem;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
+ const fileSizeExceeded = file.size >= allowSize;
- if (upload.status === UploadStatus.Idle) startUpload();
+ if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
+ startUpload();
+ }
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
@@ -131,7 +138,7 @@ export function UploadCardRenderer({
{fileItem.originalFile.type.startsWith('image') && (
)}
- {upload.status === UploadStatus.Idle && (
+ {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
)}
{upload.status === UploadStatus.Loading && (
@@ -142,6 +149,15 @@ export function UploadCardRenderer({
{upload.error.message}
)}
+ {upload.status === UploadStatus.Idle && fileSizeExceeded && (
+
+
+ The file size exceeds the limit. Maximum allowed size is{' '}
+ {bytesToSize(allowSize)}, but the uploaded file is{' '}
+ {bytesToSize(file.size)}.
+
+
+ )}
>
}
>
From faa952295f71acc605c68c798e5adc955a4bcf69 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Tue, 5 Aug 2025 18:37:07 +0530
Subject: [PATCH 05/39] Redesign space/room creation panel (#2408)
* add new create room
* rename create room modal file
* default restrict access for space children in room create modal
* move create room kind selector to components
* add radii variant to sequence card component
* more more reusable create room logic to components
* add create space
* update address input description
* add new space modal
* fix add room button visible on left room in space lobby
---
.../create-room/CreateRoomAliasInput.tsx | 118 ++++++++
.../create-room/CreateRoomKindSelector.tsx | 94 ++++++
.../create-room/RoomVersionSelector.tsx | 117 ++++++++
src/app/components/create-room/index.ts | 4 +
src/app/components/create-room/utils.ts | 131 +++++++++
.../components/sequence-card/SequenceCard.tsx | 37 ++-
src/app/components/sequence-card/style.css.ts | 46 ++-
.../common-settings/general/RoomJoinRules.tsx | 12 +-
src/app/features/create-room/CreateRoom.tsx | 277 ++++++++++++++++++
.../features/create-room/CreateRoomModal.tsx | 95 ++++++
src/app/features/create-room/index.ts | 2 +
src/app/features/create-space/CreateSpace.tsx | 249 ++++++++++++++++
.../create-space/CreateSpaceModal.tsx | 95 ++++++
src/app/features/create-space/index.ts | 2 +
src/app/features/lobby/Lobby.tsx | 8 +-
src/app/features/lobby/SpaceItem.tsx | 12 +-
src/app/hooks/router/useCreateSelected.ts | 12 +
src/app/pages/Router.tsx | 10 +-
src/app/pages/client/SidebarNav.tsx | 18 +-
src/app/pages/client/create/Create.tsx | 38 +++
src/app/pages/client/create/index.ts | 1 +
src/app/pages/client/home/CreateRoom.tsx | 56 ++++
src/app/pages/client/home/Home.tsx | 22 +-
src/app/pages/client/sidebar/CreateTab.tsx | 111 +++++++
src/app/pages/pathUtils.ts | 3 +
src/app/pages/paths.ts | 2 +
src/app/state/createRoomModal.ts | 7 +
src/app/state/createSpaceModal.ts | 7 +
src/app/state/hooks/createRoomModal.ts | 34 +++
src/app/state/hooks/createSpaceModal.ts | 34 +++
src/app/styles/ContainerColor.css.ts | 16 +-
src/app/utils/common.ts | 7 +
src/app/utils/matrix.ts | 13 +
33 files changed, 1637 insertions(+), 53 deletions(-)
create mode 100644 src/app/components/create-room/CreateRoomAliasInput.tsx
create mode 100644 src/app/components/create-room/CreateRoomKindSelector.tsx
create mode 100644 src/app/components/create-room/RoomVersionSelector.tsx
create mode 100644 src/app/components/create-room/index.ts
create mode 100644 src/app/components/create-room/utils.ts
create mode 100644 src/app/features/create-room/CreateRoom.tsx
create mode 100644 src/app/features/create-room/CreateRoomModal.tsx
create mode 100644 src/app/features/create-room/index.ts
create mode 100644 src/app/features/create-space/CreateSpace.tsx
create mode 100644 src/app/features/create-space/CreateSpaceModal.tsx
create mode 100644 src/app/features/create-space/index.ts
create mode 100644 src/app/hooks/router/useCreateSelected.ts
create mode 100644 src/app/pages/client/create/Create.tsx
create mode 100644 src/app/pages/client/create/index.ts
create mode 100644 src/app/pages/client/home/CreateRoom.tsx
create mode 100644 src/app/pages/client/sidebar/CreateTab.tsx
create mode 100644 src/app/state/createRoomModal.ts
create mode 100644 src/app/state/createSpaceModal.ts
create mode 100644 src/app/state/hooks/createRoomModal.ts
create mode 100644 src/app/state/hooks/createSpaceModal.ts
diff --git a/src/app/components/create-room/CreateRoomAliasInput.tsx b/src/app/components/create-room/CreateRoomAliasInput.tsx
new file mode 100644
index 00000000..e84658c0
--- /dev/null
+++ b/src/app/components/create-room/CreateRoomAliasInput.tsx
@@ -0,0 +1,118 @@
+import React, {
+ FormEventHandler,
+ KeyboardEventHandler,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
+import { isKeyHotkey } from 'is-hotkey';
+import { getMxIdServer } from '../../utils/matrix';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { replaceSpaceWithDash } from '../../utils/common';
+import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
+import { useDebounce } from '../../hooks/useDebounce';
+
+export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
+ const mx = useMatrixClient();
+ const aliasInputRef = useRef
(null);
+ const [aliasAvail, setAliasAvail] = useState>({
+ status: AsyncStatus.Idle,
+ });
+
+ useEffect(() => {
+ if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
+ setAliasAvail({ status: AsyncStatus.Idle });
+ }
+ }, [aliasAvail]);
+
+ const checkAliasAvail = useAsync(
+ useCallback(
+ async (aliasLocalPart: string) => {
+ const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
+ try {
+ const result = await mx.getRoomIdForAlias(roomAlias);
+ return typeof result.room_id !== 'string';
+ } catch (e) {
+ if (e instanceof MatrixError && e.httpStatus === 404) {
+ return true;
+ }
+ throw e;
+ }
+ },
+ [mx]
+ ),
+ setAliasAvail
+ );
+ const aliasAvailable: boolean | undefined =
+ aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
+
+ const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
+
+ const handleAliasChange: FormEventHandler = (evt) => {
+ const aliasInput = evt.currentTarget;
+ const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
+ if (aliasLocalPart) {
+ aliasInput.value = aliasLocalPart;
+ debounceCheckAliasAvail(aliasLocalPart);
+ } else {
+ setAliasAvail({ status: AsyncStatus.Idle });
+ }
+ };
+
+ const handleAliasKeyDown: KeyboardEventHandler = (evt) => {
+ if (isKeyHotkey('enter', evt)) {
+ evt.preventDefault();
+
+ const aliasInput = evt.currentTarget;
+ const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
+ if (aliasLocalPart) {
+ checkAliasAvail(aliasLocalPart);
+ } else {
+ setAliasAvail({ status: AsyncStatus.Idle });
+ }
+ }
+ };
+
+ return (
+
+ Address (Optional)
+
+ Pick an unique address to make it discoverable.
+
+
+ ) : (
+
+ )
+ }
+ after={
+
+ :{getMxIdServer(mx.getSafeUserId())}
+
+ }
+ onKeyDown={handleAliasKeyDown}
+ name="aliasInput"
+ size="500"
+ variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
+ radii="400"
+ autoComplete="off"
+ disabled={disabled}
+ />
+ {aliasAvailable === false && (
+
+
+
+ This address is already taken. Please select a different one.
+
+
+ )}
+
+ );
+}
diff --git a/src/app/components/create-room/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomKindSelector.tsx
new file mode 100644
index 00000000..096954fb
--- /dev/null
+++ b/src/app/components/create-room/CreateRoomKindSelector.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
+import { SequenceCard } from '../sequence-card';
+import { SettingTile } from '../setting-tile';
+
+export enum CreateRoomKind {
+ Private = 'private',
+ Restricted = 'restricted',
+ Public = 'public',
+}
+type CreateRoomKindSelectorProps = {
+ value?: CreateRoomKind;
+ onSelect: (value: CreateRoomKind) => void;
+ canRestrict?: boolean;
+ disabled?: boolean;
+ getIcon: (kind: CreateRoomKind) => IconSrc;
+};
+export function CreateRoomKindSelector({
+ value,
+ onSelect,
+ canRestrict,
+ disabled,
+ getIcon,
+}: CreateRoomKindSelectorProps) {
+ return (
+
+ {canRestrict && (
+ onSelect(CreateRoomKind.Restricted)}
+ disabled={disabled}
+ >
+ }
+ after={value === CreateRoomKind.Restricted && }
+ >
+ Restricted
+
+ Only member of parent space can join.
+
+
+
+ )}
+ onSelect(CreateRoomKind.Private)}
+ disabled={disabled}
+ >
+ }
+ after={value === CreateRoomKind.Private && }
+ >
+ Private
+
+ Only people with invite can join.
+
+
+
+ onSelect(CreateRoomKind.Public)}
+ disabled={disabled}
+ >
+ }
+ after={value === CreateRoomKind.Public && }
+ >
+ Public
+
+ Anyone with the address can join.
+
+
+
+
+ );
+}
diff --git a/src/app/components/create-room/RoomVersionSelector.tsx b/src/app/components/create-room/RoomVersionSelector.tsx
new file mode 100644
index 00000000..281f520a
--- /dev/null
+++ b/src/app/components/create-room/RoomVersionSelector.tsx
@@ -0,0 +1,117 @@
+import React, { MouseEventHandler, useState } from 'react';
+import {
+ Box,
+ Button,
+ Chip,
+ config,
+ Icon,
+ Icons,
+ Menu,
+ PopOut,
+ RectCords,
+ Text,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { SettingTile } from '../setting-tile';
+import { SequenceCard } from '../sequence-card';
+import { stopPropagation } from '../../utils/keyboard';
+
+export function RoomVersionSelector({
+ versions,
+ value,
+ onChange,
+ disabled,
+}: {
+ versions: string[];
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+}) {
+ const [menuCords, setMenuCords] = useState();
+
+ const handleMenu: MouseEventHandler = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleSelect = (version: string) => {
+ setMenuCords(undefined);
+ onChange(version);
+ };
+
+ return (
+
+ setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ >
+ }
+ disabled={disabled}
+ >
+ {value}
+
+
+ }
+ />
+
+ );
+}
diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts
new file mode 100644
index 00000000..ffca558d
--- /dev/null
+++ b/src/app/components/create-room/index.ts
@@ -0,0 +1,4 @@
+export * from './CreateRoomKindSelector';
+export * from './CreateRoomAliasInput';
+export * from './RoomVersionSelector';
+export * from './utils';
diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts
new file mode 100644
index 00000000..9abff4ff
--- /dev/null
+++ b/src/app/components/create-room/utils.ts
@@ -0,0 +1,131 @@
+import {
+ ICreateRoomOpts,
+ ICreateRoomStateEvent,
+ JoinRule,
+ MatrixClient,
+ RestrictedAllowType,
+ Room,
+} from 'matrix-js-sdk';
+import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
+import { CreateRoomKind } from './CreateRoomKindSelector';
+import { RoomType, StateEvent } from '../../../types/matrix/room';
+import { getViaServers } from '../../plugins/via-servers';
+import { getMxIdServer } from '../../utils/matrix';
+
+export const createRoomCreationContent = (
+ type: RoomType | undefined,
+ allowFederation: boolean
+): object => {
+ const content: Record = {};
+ if (typeof type === 'string') {
+ content.type = type;
+ }
+ if (allowFederation === false) {
+ content['m.federate'] = false;
+ }
+
+ return content;
+};
+
+export const createRoomJoinRulesState = (
+ kind: CreateRoomKind,
+ parent: Room | undefined,
+ knock: boolean
+) => {
+ let content: RoomJoinRulesEventContent = {
+ join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
+ };
+
+ if (kind === CreateRoomKind.Public) {
+ content = {
+ join_rule: JoinRule.Public,
+ };
+ }
+
+ if (kind === CreateRoomKind.Restricted && parent) {
+ content = {
+ join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
+ allow: [
+ {
+ type: RestrictedAllowType.RoomMembership,
+ room_id: parent.roomId,
+ },
+ ],
+ };
+ }
+
+ return {
+ type: StateEvent.RoomJoinRules,
+ state_key: '',
+ content,
+ };
+};
+
+export const createRoomParentState = (parent: Room) => ({
+ type: StateEvent.SpaceParent,
+ state_key: parent.roomId,
+ content: {
+ canonical: true,
+ via: getViaServers(parent),
+ },
+});
+
+export const createRoomEncryptionState = () => ({
+ type: 'm.room.encryption',
+ state_key: '',
+ content: {
+ algorithm: 'm.megolm.v1.aes-sha2',
+ },
+});
+
+export type CreateRoomData = {
+ version: string;
+ type?: RoomType;
+ parent?: Room;
+ kind: CreateRoomKind;
+ name: string;
+ topic?: string;
+ aliasLocalPart?: string;
+ encryption?: boolean;
+ knock: boolean;
+ allowFederation: boolean;
+};
+export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => {
+ const initialState: ICreateRoomStateEvent[] = [];
+
+ if (data.encryption) {
+ initialState.push(createRoomEncryptionState());
+ }
+
+ if (data.parent) {
+ initialState.push(createRoomParentState(data.parent));
+ }
+
+ initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
+
+ const options: ICreateRoomOpts = {
+ room_version: data.version,
+ name: data.name,
+ topic: data.topic,
+ room_alias_name: data.aliasLocalPart,
+ creation_content: createRoomCreationContent(data.type, data.allowFederation),
+ initial_state: initialState,
+ };
+
+ const result = await mx.createRoom(options);
+
+ if (data.parent) {
+ await mx.sendStateEvent(
+ data.parent.roomId,
+ StateEvent.SpaceChild as any,
+ {
+ auto_join: false,
+ suggested: false,
+ via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
+ },
+ result.room_id
+ );
+ }
+
+ return result.room_id;
+};
diff --git a/src/app/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx
index 4036b963..d0e77ae6 100644
--- a/src/app/components/sequence-card/SequenceCard.tsx
+++ b/src/app/components/sequence-card/SequenceCard.tsx
@@ -7,12 +7,31 @@ import * as css from './style.css';
export const SequenceCard = as<
'div',
ComponentProps & ContainerColorVariants & css.SequenceCardVariants
->(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
-
-));
+>(
+ (
+ {
+ as: AsSequenceCard = 'div',
+ className,
+ variant,
+ radii,
+ firstChild,
+ lastChild,
+ outlined,
+ ...props
+ },
+ ref
+ ) => (
+
+ )
+);
diff --git a/src/app/components/sequence-card/style.css.ts b/src/app/components/sequence-card/style.css.ts
index c8ed48b8..9d503264 100644
--- a/src/app/components/sequence-card/style.css.ts
+++ b/src/app/components/sequence-card/style.css.ts
@@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { config } from 'folds';
const outlinedWidth = createVar('0');
+const radii = createVar(config.radii.R400);
export const SequenceCard = recipe({
base: {
vars: {
@@ -13,33 +14,59 @@ export const SequenceCard = recipe({
borderBottomWidth: 0,
selectors: {
'&:first-child, :not(&) + &': {
- borderTopLeftRadius: config.radii.R400,
- borderTopRightRadius: config.radii.R400,
+ borderTopLeftRadius: [radii],
+ borderTopRightRadius: [radii],
},
'&:last-child, &:not(:has(+&))': {
- borderBottomLeftRadius: config.radii.R400,
- borderBottomRightRadius: config.radii.R400,
+ borderBottomLeftRadius: [radii],
+ borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth,
},
[`&[data-first-child="true"]`]: {
- borderTopLeftRadius: config.radii.R400,
- borderTopRightRadius: config.radii.R400,
+ borderTopLeftRadius: [radii],
+ borderTopRightRadius: [radii],
},
[`&[data-first-child="false"]`]: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
[`&[data-last-child="true"]`]: {
- borderBottomLeftRadius: config.radii.R400,
- borderBottomRightRadius: config.radii.R400,
+ borderBottomLeftRadius: [radii],
+ borderBottomRightRadius: [radii],
},
[`&[data-last-child="false"]`]: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
+
+ 'button&': {
+ cursor: 'pointer',
+ },
},
},
variants: {
+ radii: {
+ '0': {
+ vars: {
+ [radii]: config.radii.R0,
+ },
+ },
+ '300': {
+ vars: {
+ [radii]: config.radii.R300,
+ },
+ },
+ '400': {
+ vars: {
+ [radii]: config.radii.R400,
+ },
+ },
+ '500': {
+ vars: {
+ [radii]: config.radii.R500,
+ },
+ },
+ },
outlined: {
true: {
vars: {
@@ -48,5 +75,8 @@ export const SequenceCard = recipe({
},
},
},
+ defaultVariants: {
+ radii: '400',
+ },
});
export type SequenceCardVariants = RecipeVariants;
diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx
index c0d62a6a..f47ff757 100644
--- a/src/app/features/common-settings/general/RoomJoinRules.tsx
+++ b/src/app/features/common-settings/general/RoomJoinRules.tsx
@@ -27,6 +27,11 @@ import {
} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import {
+ knockRestrictedSupported,
+ knockSupported,
+ restrictedSupported,
+} from '../../../utils/matrix';
type RestrictedRoomAllowContent = {
room_id: string;
@@ -39,10 +44,9 @@ type RoomJoinRulesProps = {
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const mx = useMatrixClient();
const room = useRoom();
- const roomVersion = parseInt(room.getVersion(), 10);
- const allowKnockRestricted = roomVersion >= 10;
- const allowRestricted = roomVersion >= 8;
- const allowKnock = roomVersion >= 7;
+ const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
+ const allowRestricted = restrictedSupported(room.getVersion());
+ const allowKnock = knockSupported(room.getVersion());
const roomIdToParents = useAtomValue(roomToParentsAtom);
const space = useSpaceOptionally();
diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx
new file mode 100644
index 00000000..c88bf680
--- /dev/null
+++ b/src/app/features/create-room/CreateRoom.tsx
@@ -0,0 +1,277 @@
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import {
+ Box,
+ Button,
+ Chip,
+ color,
+ config,
+ Icon,
+ Icons,
+ Input,
+ Spinner,
+ Switch,
+ Text,
+ TextArea,
+} from 'folds';
+import { SettingTile } from '../../components/setting-tile';
+import { SequenceCard } from '../../components/sequence-card';
+import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { useCapabilities } from '../../hooks/useCapabilities';
+import { useAlive } from '../../hooks/useAlive';
+import { ErrorCode } from '../../cs-errorcode';
+import {
+ createRoom,
+ CreateRoomAliasInput,
+ CreateRoomData,
+ CreateRoomKind,
+ CreateRoomKindSelector,
+ RoomVersionSelector,
+} from '../../components/create-room';
+
+const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
+ if (kind === CreateRoomKind.Private) return Icons.HashLock;
+ if (kind === CreateRoomKind.Restricted) return Icons.Hash;
+ return Icons.HashGlobe;
+};
+
+type CreateRoomFormProps = {
+ defaultKind?: CreateRoomKind;
+ space?: Room;
+ onCreate?: (roomId: string) => void;
+};
+export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
+ const mx = useMatrixClient();
+ const alive = useAlive();
+
+ const capabilities = useCapabilities();
+ const roomVersions = capabilities['m.room_versions'];
+ const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
+
+ const allowRestricted = space && restrictedSupported(selectedRoomVersion);
+
+ const [kind, setKind] = useState(
+ defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
+ );
+ const [federation, setFederation] = useState(true);
+ const [encryption, setEncryption] = useState(false);
+ const [knock, setKnock] = useState(false);
+ const [advance, setAdvance] = useState(false);
+
+ const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
+ const allowKnockRestricted =
+ kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
+
+ const handleRoomVersionChange = (version: string) => {
+ if (!restrictedSupported(version)) {
+ setKind(CreateRoomKind.Private);
+ }
+ selectRoomVersion(version);
+ };
+
+ const [createState, create] = useAsyncCallback(
+ useCallback((data) => createRoom(mx, data), [mx])
+ );
+ const loading = createState.status === AsyncStatus.Loading;
+ const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
+ const disabled = createState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ if (disabled) return;
+ const form = evt.currentTarget;
+
+ const nameInput = form.nameInput as HTMLInputElement | undefined;
+ const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
+ const aliasInput = form.aliasInput as HTMLInputElement | undefined;
+ const roomName = nameInput?.value.trim();
+ const roomTopic = topicTextArea?.value.trim();
+ const aliasLocalPart =
+ aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
+
+ if (!roomName) return;
+ const publicRoom = kind === CreateRoomKind.Public;
+ let roomKnock = false;
+ if (allowKnock && kind === CreateRoomKind.Private) {
+ roomKnock = knock;
+ }
+ if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
+ roomKnock = knock;
+ }
+
+ create({
+ version: selectedRoomVersion,
+ parent: space,
+ kind,
+ name: roomName,
+ topic: roomTopic || undefined,
+ aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
+ encryption: publicRoom ? false : encryption,
+ knock: roomKnock,
+ allowFederation: federation,
+ }).then((roomId) => {
+ if (alive()) {
+ onCreate?.(roomId);
+ }
+ });
+ };
+
+ return (
+
+
+ Access
+
+
+
+ Name
+ }
+ name="nameInput"
+ autoFocus
+ size="500"
+ variant="SurfaceVariant"
+ radii="400"
+ autoComplete="off"
+ disabled={disabled}
+ />
+
+
+ Topic (Optional)
+
+
+
+ {kind === CreateRoomKind.Public && }
+
+
+
+ Options
+
+ }
+ onClick={() => setAdvance(!advance)}
+ type="button"
+ >
+ Advance Options
+
+
+
+ {kind !== CreateRoomKind.Public && (
+ <>
+
+
+ }
+ />
+
+ {advance && (allowKnock || allowKnockRestricted) && (
+
+
+ }
+ />
+
+ )}
+ >
+ )}
+
+
+
+ }
+ />
+
+ {advance && (
+
+ )}
+
+
+ {error && (
+
+
+
+
+ {error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
+ ? `Server rate-limited your request for ${millisecondsToMinutes(
+ (error.data.retry_after_ms as number | undefined) ?? 0
+ )} minutes!`
+ : error.message}
+
+
+
+ )}
+
+ }
+ >
+ Create
+
+
+
+ );
+}
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
new file mode 100644
index 00000000..c1c9ba3e
--- /dev/null
+++ b/src/app/features/create-room/CreateRoomModal.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import {
+ Box,
+ config,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Text,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+import { CreateRoomForm } from './CreateRoom';
+import {
+ useCloseCreateRoomModal,
+ useCreateRoomModalState,
+} from '../../state/hooks/createRoomModal';
+import { CreateRoomModalState } from '../../state/createRoomModal';
+import { stopPropagation } from '../../utils/keyboard';
+
+type CreateRoomModalProps = {
+ state: CreateRoomModalState;
+};
+function CreateRoomModal({ state }: CreateRoomModalProps) {
+ const { spaceId } = state;
+ const closeDialog = useCloseCreateRoomModal();
+
+ const allJoinedRooms = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allJoinedRooms);
+ const space = spaceId ? getRoom(spaceId) : undefined;
+
+ return (
+
+ }>
+
+
+
+
+
+
+ New Room
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function CreateRoomModalRenderer() {
+ const state = useCreateRoomModalState();
+
+ if (!state) return null;
+ return ;
+}
diff --git a/src/app/features/create-room/index.ts b/src/app/features/create-room/index.ts
new file mode 100644
index 00000000..f60c94b3
--- /dev/null
+++ b/src/app/features/create-room/index.ts
@@ -0,0 +1,2 @@
+export * from './CreateRoom';
+export * from './CreateRoomModal';
diff --git a/src/app/features/create-space/CreateSpace.tsx b/src/app/features/create-space/CreateSpace.tsx
new file mode 100644
index 00000000..d964152a
--- /dev/null
+++ b/src/app/features/create-space/CreateSpace.tsx
@@ -0,0 +1,249 @@
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import {
+ Box,
+ Button,
+ Chip,
+ color,
+ config,
+ Icon,
+ Icons,
+ Input,
+ Spinner,
+ Switch,
+ Text,
+ TextArea,
+} from 'folds';
+import { SettingTile } from '../../components/setting-tile';
+import { SequenceCard } from '../../components/sequence-card';
+import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { useCapabilities } from '../../hooks/useCapabilities';
+import { useAlive } from '../../hooks/useAlive';
+import { ErrorCode } from '../../cs-errorcode';
+import {
+ createRoom,
+ CreateRoomAliasInput,
+ CreateRoomData,
+ CreateRoomKind,
+ CreateRoomKindSelector,
+ RoomVersionSelector,
+} from '../../components/create-room';
+import { RoomType } from '../../../types/matrix/room';
+
+const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
+ if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
+ if (kind === CreateRoomKind.Restricted) return Icons.Space;
+ return Icons.SpaceGlobe;
+};
+
+type CreateSpaceFormProps = {
+ defaultKind?: CreateRoomKind;
+ space?: Room;
+ onCreate?: (roomId: string) => void;
+};
+export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
+ const mx = useMatrixClient();
+ const alive = useAlive();
+
+ const capabilities = useCapabilities();
+ const roomVersions = capabilities['m.room_versions'];
+ const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
+
+ const allowRestricted = space && restrictedSupported(selectedRoomVersion);
+
+ const [kind, setKind] = useState(
+ defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
+ );
+ const [federation, setFederation] = useState(true);
+ const [knock, setKnock] = useState(false);
+ const [advance, setAdvance] = useState(false);
+
+ const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
+ const allowKnockRestricted =
+ kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
+
+ const handleRoomVersionChange = (version: string) => {
+ if (!restrictedSupported(version)) {
+ setKind(CreateRoomKind.Private);
+ }
+ selectRoomVersion(version);
+ };
+
+ const [createState, create] = useAsyncCallback(
+ useCallback((data) => createRoom(mx, data), [mx])
+ );
+ const loading = createState.status === AsyncStatus.Loading;
+ const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
+ const disabled = createState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ if (disabled) return;
+ const form = evt.currentTarget;
+
+ const nameInput = form.nameInput as HTMLInputElement | undefined;
+ const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
+ const aliasInput = form.aliasInput as HTMLInputElement | undefined;
+ const roomName = nameInput?.value.trim();
+ const roomTopic = topicTextArea?.value.trim();
+ const aliasLocalPart =
+ aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
+
+ if (!roomName) return;
+ const publicRoom = kind === CreateRoomKind.Public;
+ let roomKnock = false;
+ if (allowKnock && kind === CreateRoomKind.Private) {
+ roomKnock = knock;
+ }
+ if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
+ roomKnock = knock;
+ }
+
+ create({
+ version: selectedRoomVersion,
+ type: RoomType.Space,
+ parent: space,
+ kind,
+ name: roomName,
+ topic: roomTopic || undefined,
+ aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
+ knock: roomKnock,
+ allowFederation: federation,
+ }).then((roomId) => {
+ if (alive()) {
+ onCreate?.(roomId);
+ }
+ });
+ };
+
+ return (
+
+
+ Access
+
+
+
+ Name
+ }
+ name="nameInput"
+ autoFocus
+ size="500"
+ variant="SurfaceVariant"
+ radii="400"
+ autoComplete="off"
+ disabled={disabled}
+ />
+
+
+ Topic (Optional)
+
+
+
+ {kind === CreateRoomKind.Public && }
+
+
+
+ Options
+
+ }
+ onClick={() => setAdvance(!advance)}
+ type="button"
+ >
+ Advance Options
+
+
+
+ {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
+
+
+ }
+ />
+
+ )}
+
+
+
+ }
+ />
+
+ {advance && (
+
+ )}
+
+
+ {error && (
+
+
+
+
+ {error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
+ ? `Server rate-limited your request for ${millisecondsToMinutes(
+ (error.data.retry_after_ms as number | undefined) ?? 0
+ )} minutes!`
+ : error.message}
+
+
+
+ )}
+
+ }
+ >
+ Create
+
+
+
+ );
+}
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
new file mode 100644
index 00000000..c1bc6898
--- /dev/null
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import {
+ Box,
+ config,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Text,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+import { CreateSpaceForm } from './CreateSpace';
+import {
+ useCloseCreateSpaceModal,
+ useCreateSpaceModalState,
+} from '../../state/hooks/createSpaceModal';
+import { CreateSpaceModalState } from '../../state/createSpaceModal';
+import { stopPropagation } from '../../utils/keyboard';
+
+type CreateSpaceModalProps = {
+ state: CreateSpaceModalState;
+};
+function CreateSpaceModal({ state }: CreateSpaceModalProps) {
+ const { spaceId } = state;
+ const closeDialog = useCloseCreateSpaceModal();
+
+ const allJoinedRooms = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allJoinedRooms);
+ const space = spaceId ? getRoom(spaceId) : undefined;
+
+ return (
+
+ }>
+
+
+
+
+
+
+ New Space
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function CreateSpaceModalRenderer() {
+ const state = useCreateSpaceModalState();
+
+ if (!state) return null;
+ return ;
+}
diff --git a/src/app/features/create-space/index.ts b/src/app/features/create-space/index.ts
new file mode 100644
index 00000000..d203993b
--- /dev/null
+++ b/src/app/features/create-space/index.ts
@@ -0,0 +1,2 @@
+export * from './CreateSpace';
+export * from './CreateSpaceModal';
diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx
index 069e925e..45610ff3 100644
--- a/src/app/features/lobby/Lobby.tsx
+++ b/src/app/features/lobby/Lobby.tsx
@@ -220,14 +220,12 @@ export function Lobby() {
() =>
hierarchy
.flatMap((i) => {
- const childRooms = Array.isArray(i.rooms)
- ? i.rooms.map((r) => mx.getRoom(r.roomId))
- : [];
+ const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
- return [mx.getRoom(i.space.roomId), ...childRooms];
+ return [getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
- [mx, hierarchy]
+ [hierarchy, getRoom]
)
);
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index 0a4d9de5..e881a971 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -30,10 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css';
import * as styleCss from './style.css';
import { useDraggableItem } from './DnD';
-import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
+import { openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
+import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
function SpaceProfileLoading() {
return (
@@ -240,13 +242,14 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState();
+ const openCreateRoomModal = useOpenCreateRoomModal();
const handleAddRoom: MouseEventHandler = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
- openCreateRoom(false, item.roomId as any);
+ openCreateRoomModal(item.roomId);
setCords(undefined);
};
@@ -303,13 +306,14 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState();
+ const openCreateSpaceModal = useOpenCreateSpaceModal();
const handleAddSpace: MouseEventHandler = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
- openCreateRoom(true, item.roomId as any);
+ openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
@@ -470,7 +474,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
>
)}
- {canEditChild && (
+ {space && canEditChild && (
{item.parentId === undefined && }
diff --git a/src/app/hooks/router/useCreateSelected.ts b/src/app/hooks/router/useCreateSelected.ts
new file mode 100644
index 00000000..2034a449
--- /dev/null
+++ b/src/app/hooks/router/useCreateSelected.ts
@@ -0,0 +1,12 @@
+import { useMatch } from 'react-router-dom';
+import { getCreatePath } from '../../pages/pathUtils';
+
+export const useCreateSelected = (): boolean => {
+ const match = useMatch({
+ path: getCreatePath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 89743693..d6d93aa4 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -28,6 +28,7 @@ import {
_ROOM_PATH,
_SEARCH_PATH,
_SERVER_PATH,
+ CREATE_PATH,
} from './paths';
import { isAuthenticated } from '../../client/state/auth';
import {
@@ -61,6 +62,10 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings';
+import { CreateRoomModalRenderer } from '../features/create-room';
+import { HomeCreateRoom } from './client/home/CreateRoom';
+import { Create } from './client/create';
+import { CreateSpaceModalRenderer } from '../features/create-space';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@@ -125,6 +130,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
>
+
+
@@ -152,7 +159,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
{mobile ? null : } />}
- create} />
+ } />
join} />
} />
} />
} />
+ } />
(null);
@@ -37,20 +38,7 @@ export function SidebarNav() {
-
-
- {(triggerRef) => (
- openCreateRoom(true)}
- >
-
-
- )}
-
-
+
}
diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx
new file mode 100644
index 00000000..288169b6
--- /dev/null
+++ b/src/app/pages/client/create/Create.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Box, Icon, Icons, Scroll } from 'folds';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHero,
+ PageHeroSection,
+} from '../../../components/page';
+import { CreateSpaceForm } from '../../../features/create-space';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+
+export function Create() {
+ const { navigateSpace } = useRoomNavigate();
+
+ return (
+
+
+
+
+
+
+
+ }
+ title="Create Space"
+ subTitle="Build a space for your community."
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/create/index.ts b/src/app/pages/client/create/index.ts
new file mode 100644
index 00000000..48cba6e7
--- /dev/null
+++ b/src/app/pages/client/create/index.ts
@@ -0,0 +1 @@
+export * from './Create';
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
new file mode 100644
index 00000000..20c01bae
--- /dev/null
+++ b/src/app/pages/client/home/CreateRoom.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHeader,
+ PageHero,
+ PageHeroSection,
+} from '../../../components/page';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { CreateRoomForm } from '../../../features/create-room';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+
+export function HomeCreateRoom() {
+ const screenSize = useScreenSizeContext();
+
+ const { navigateRoom } = useRoomNavigate();
+
+ return (
+
+ {screenSize === ScreenSize.Mobile && (
+
+
+
+ {(onBack) => (
+
+
+
+ )}
+
+
+
+ )}
+
+
+
+
+
+
+ }
+ title="Create Room"
+ subTitle="Build a Room for Real-Time Conversations"
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index af4164fd..d2333919 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -29,10 +29,18 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
-import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
+import {
+ getExplorePath,
+ getHomeCreatePath,
+ getHomeRoomPath,
+ getHomeSearchPath,
+} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
+import {
+ useHomeCreateSelected,
+ useHomeSearchSelected,
+} from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { VirtualTile } from '../../../components/virtualizer';
@@ -41,7 +49,7 @@ import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
-import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
+import { openJoinAlias } from '../../../../client/action/navigation';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications';
@@ -174,7 +182,7 @@ function HomeEmpty() {
}
options={
<>
-
+
+
+ );
+}
+
+type UserInviteAlertProps = {
+ userId: string;
+ reason?: string;
+ canKick?: boolean;
+ invitedBy?: string;
+ ts?: number;
+};
+export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
+ const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
+ const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
+
+ const [kickState, kick] = useAsyncCallback(
+ useCallback(async () => {
+ await mx.kick(room.roomId, userId);
+ }, [mx, room, userId])
+ );
+ const kicking = kickState.status === AsyncStatus.Loading;
+ const error = kickState.status === AsyncStatus.Error;
+
+ return (
+
+
+
+
+ Invited User
+ {time && date && (
+
+ {date} {time}
+
+ )}
+
+
+ {invitedBy && (
+
+ Invited by: {invitedBy}
+
+ )}
+
+ {reason ? (
+ <>
+ Reason: {reason}
+ >
+ ) : (
+ No Reason Provided.
+ )}
+
+
+ {error && (
+
+ {kickState.error.message}
+
+ )}
+ {canKick && (
+ }
+ disabled={kicking}
+ >
+ Cancel Invite
+
+ )}
+
+
+
+ );
+}
+
+type UserModerationProps = {
+ userId: string;
+ canKick: boolean;
+ canBan: boolean;
+ canInvite: boolean;
+};
+export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const reasonInputRef = useRef(null);
+
+ const getReason = useCallback((): string | undefined => {
+ const reason = reasonInputRef.current?.value.trim() || undefined;
+ if (reasonInputRef.current) {
+ reasonInputRef.current.value = '';
+ }
+ return reason;
+ }, []);
+
+ const [kickState, kick] = useAsyncCallback(
+ useCallback(async () => {
+ await mx.kick(room.roomId, userId, getReason());
+ }, [mx, room, userId, getReason])
+ );
+
+ const [banState, ban] = useAsyncCallback(
+ useCallback(async () => {
+ await mx.ban(room.roomId, userId, getReason());
+ }, [mx, room, userId, getReason])
+ );
+
+ const [inviteState, invite] = useAsyncCallback(
+ useCallback(async () => {
+ await mx.invite(room.roomId, userId, getReason());
+ }, [mx, room, userId, getReason])
+ );
+
+ const disabled =
+ kickState.status === AsyncStatus.Loading ||
+ banState.status === AsyncStatus.Loading ||
+ inviteState.status === AsyncStatus.Loading;
+
+ if (!canBan && !canKick && !canInvite) return null;
+
+ return (
+
+
+
+ Moderation
+
+ {kickState.status === AsyncStatus.Error && (
+
+ {kickState.error.message}
+
+ )}
+ {banState.status === AsyncStatus.Error && (
+
+ {banState.error.message}
+
+ )}
+ {inviteState.status === AsyncStatus.Error && (
+
+ {inviteState.error.message}
+
+ )}
+
+
+ {canInvite && (
+
+ ) : (
+
+ )
+ }
+ onClick={invite}
+ disabled={disabled}
+ >
+ Invite
+
+ )}
+ {canKick && (
+
+ ) : (
+
+ )
+ }
+ onClick={kick}
+ disabled={disabled}
+ >
+ Kick
+
+ )}
+ {canBan && (
+
+ ) : (
+
+ )
+ }
+ onClick={ban}
+ disabled={disabled}
+ >
+ Ban
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
new file mode 100644
index 00000000..ad23fef1
--- /dev/null
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -0,0 +1,159 @@
+import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds';
+import React, { useCallback } from 'react';
+import { UserHero, UserHeroName } from './UserHero';
+import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
+import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { useRoom } from '../../hooks/useRoom';
+import { useUserPresence } from '../../hooks/useUserPresence';
+import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { createDM, ignore } from '../../../client/action/room';
+import { hasDevices } from '../../../util/matrixUtil';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { useAlive } from '../../hooks/useAlive';
+import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
+import { PowerChip } from './PowerChip';
+import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
+import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
+import { useMembership } from '../../hooks/useMembership';
+import { Membership } from '../../../types/matrix/room';
+
+type UserRoomProfileProps = {
+ userId: string;
+};
+export function UserRoomProfile({ userId }: UserRoomProfileProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const { navigateRoom } = useRoomNavigate();
+ const alive = useAlive();
+ const closeUserRoomProfile = useCloseUserRoomProfile();
+ const ignoredUsers = useIgnoredUsers();
+ const ignored = ignoredUsers.includes(userId);
+
+ const room = useRoom();
+ const powerlevels = usePowerLevels(room);
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerlevels);
+ const myPowerLevel = getPowerLevel(mx.getSafeUserId());
+ const userPowerLevel = getPowerLevel(userId);
+ const canKick = canDoAction('kick', myPowerLevel) && myPowerLevel > userPowerLevel;
+ const canBan = canDoAction('ban', myPowerLevel) && myPowerLevel > userPowerLevel;
+ const canInvite = canDoAction('invite', myPowerLevel);
+
+ const member = room.getMember(userId);
+ const membership = useMembership(room, userId);
+
+ const server = getMxIdServer(userId);
+ const displayName = getMemberDisplayName(room, userId);
+ const avatarMxc = getMemberAvatarMxc(room, userId);
+ const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
+
+ const presence = useUserPresence(userId);
+
+ const [directMessageState, directMessage] = useAsyncCallback(
+ useCallback(async () => {
+ const result = await createDM(mx, userId, await hasDevices(mx, userId));
+ return result.room_id as string;
+ }, [userId, mx])
+ );
+
+ const handleMessage = () => {
+ const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
+ if (dmRoomId) {
+ navigateRoom(dmRoomId);
+ closeUserRoomProfile();
+ return;
+ }
+ directMessage().then((rId) => {
+ if (alive()) {
+ navigateRoom(rId);
+ closeUserRoomProfile();
+ }
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleMessage}
+ >
+ Message
+
+
+
+ {directMessageState.status === AsyncStatus.Error && (
+
+ {directMessageState.error.message}
+
+ )}
+
+ {server && }
+
+
+
+
+
+
+ {ignored && }
+ {member && membership === Membership.Ban && (
+
+ )}
+ {member &&
+ membership === Membership.Leave &&
+ member.events.member &&
+ member.events.member.getSender() !== userId && (
+
+ )}
+ {member && membership === Membership.Invite && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/components/user-profile/index.ts b/src/app/components/user-profile/index.ts
new file mode 100644
index 00000000..54542c76
--- /dev/null
+++ b/src/app/components/user-profile/index.ts
@@ -0,0 +1 @@
+export * from './UserRoomProfile';
diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts
new file mode 100644
index 00000000..ad6d5a95
--- /dev/null
+++ b/src/app/components/user-profile/styles.css.ts
@@ -0,0 +1,42 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const UserHeader = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: 1,
+ padding: config.space.S200,
+});
+
+export const UserHero = style({
+ position: 'relative',
+});
+
+export const UserHeroCoverContainer = style({
+ height: toRem(96),
+ overflow: 'hidden',
+});
+export const UserHeroCover = style({
+ height: '100%',
+ width: '100%',
+ objectFit: 'cover',
+ filter: 'blur(16px)',
+ transform: 'scale(2)',
+});
+
+export const UserHeroAvatarContainer = style({
+ position: 'relative',
+ height: toRem(29),
+});
+export const UserAvatarContainer = style({
+ position: 'absolute',
+ left: config.space.S400,
+ top: 0,
+ transform: 'translateY(-50%)',
+ backgroundColor: color.Surface.Container,
+});
+export const UserHeroAvatar = style({
+ outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
+});
diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx
index 8d7f89fd..dc802a1c 100644
--- a/src/app/features/common-settings/members/Members.tsx
+++ b/src/app/features/common-settings/members/Members.tsx
@@ -37,7 +37,6 @@ import { MemberTile } from '../../../components/member-tile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { ServerBadge } from '../../../components/server-badge';
-import { openProfileViewer } from '../../../../client/action/navigation';
import { useDebounce } from '../../../hooks/useDebounce';
import {
SearchItemStrGetter,
@@ -53,6 +52,11 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../../components/MemberSortMenu';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
+import {
+ useOpenUserRoomProfile,
+ useUserRoomProfileState,
+} from '../../../state/hooks/userRoomProfile';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -77,6 +81,9 @@ export function Members({ requestClose }: MembersProps) {
const room = useRoom();
const members = useRoomMembers(mx, room.roomId);
const fetchingMembers = members.length < room.getJoinedMemberCount();
+ const openProfile = useOpenUserRoomProfile();
+ const profileUser = useUserRoomProfileState();
+ const space = useSpaceOptionally();
const powerLevels = usePowerLevels(room);
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
@@ -142,8 +149,9 @@ export function Members({ requestClose }: MembersProps) {
const handleMemberClick: MouseEventHandler = (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
- openProfileViewer(userId, room.roomId);
- requestClose();
+ if (userId) {
+ openProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect());
+ }
};
return (
@@ -317,6 +325,7 @@ export function Members({ requestClose }: MembersProps) {
= (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
- openProfileViewer(userId, room.roomId);
+ if (!userId) return;
+ openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left');
};
return (
@@ -350,6 +355,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
padding: `0 ${config.space.S200}`,
transform: `translateY(${vItem.start}px)`,
}}
+ aria-pressed={openProfileUserId === member.userId}
data-index={vItem.index}
data-user-id={member.userId}
ref={virtualizer.measureElement}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 244eb327..90f09012 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -85,7 +85,6 @@ import {
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
-import { openProfileViewer } from '../../../client/action/navigation';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
@@ -120,6 +119,8 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
import { useIsDirectRoom } from '../../hooks/useRoom';
+import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
+import { useSpaceOptionally } from '../../hooks/useSpace';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -472,6 +473,8 @@ export function RoomTimeline({
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
+ const openUserRoomProfile = useOpenUserRoomProfile();
+ const space = useSpaceOptionally();
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -909,9 +912,14 @@ export function RoomTimeline({
console.warn('Button should have "data-user-id" attribute!');
return;
}
- openProfileViewer(userId, room.roomId);
+ openUserRoomProfile(
+ room.roomId,
+ space?.roomId,
+ userId,
+ evt.currentTarget.getBoundingClientRect()
+ );
},
- [room]
+ [room, space, openUserRoomProfile]
);
const handleUsernameClick: MouseEventHandler = useCallback(
(evt) => {
diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
index d4b39845..0e7ca833 100644
--- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx
+++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
@@ -20,12 +20,13 @@ import { getMemberDisplayName } from '../../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
import * as css from './ReactionViewer.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { openProfileViewer } from '../../../../client/action/navigation';
import { useRelations } from '../../../hooks/useRelations';
import { Reaction } from '../../../components/message';
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
export type ReactionViewerProps = {
room: Room;
@@ -41,6 +42,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
);
+ const space = useSpaceOptionally();
+ const openProfile = useOpenUserRoomProfile();
const [selectedKey, setSelectedKey] = useState(() => {
if (initialKey) return initialKey;
@@ -111,24 +114,31 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
const avatarMxcUrl = member?.getMxcAvatarUrl();
- const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(
- avatarMxcUrl,
- 100,
- 100,
- 'crop',
- undefined,
- false,
- useAuthentication
- ) : undefined;
+ const avatarUrl = avatarMxcUrl
+ ? mx.mxcUrlToHttp(
+ avatarMxcUrl,
+ 100,
+ 100,
+ 'crop',
+ undefined,
+ false,
+ useAuthentication
+ )
+ : undefined;
return (
{promptLeave && (
setPromptLeave(false)}
/>
@@ -242,7 +246,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
}}
>
setMenuAnchor(undefined)}
/>
diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx
index a152bc19..280b8a5a 100644
--- a/src/app/features/lobby/SpaceHierarchy.tsx
+++ b/src/app/features/lobby/SpaceHierarchy.tsx
@@ -8,14 +8,16 @@ import {
HierarchyItemSpace,
useFetchSpaceHierarchyLevel,
} from '../../hooks/useSpaceHierarchy';
-import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels';
+import { IPowerLevels } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SpaceItemCard } from './SpaceItem';
import { AfterItemDropTarget, CanDropCallback } from './DnD';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { RoomItemCard } from './RoomItem';
-import { RoomType } from '../../../types/matrix/room';
+import { RoomType, StateEvent } from '../../../types/matrix/room';
import { SequenceCard } from '../../components/sequence-card';
+import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
+import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined;
@@ -24,7 +26,6 @@ type SpaceHierarchyProps = {
allJoinedRooms: Set;
mDirects: Set;
roomsPowerLevels: Map;
- canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler;
@@ -48,7 +49,6 @@ export const SpaceHierarchy = forwardRef(
allJoinedRooms,
mDirects,
roomsPowerLevels,
- canEditSpaceChild,
categoryId,
closed,
handleClose,
@@ -79,25 +79,28 @@ export const SpaceHierarchy = forwardRef(
return s;
}, [rooms]);
- const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
- const userPLInSpace = powerLevelAPI.getPowerLevel(
- spacePowerLevels,
- mx.getUserId() ?? undefined
- );
- const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
+ const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId);
+ const spaceCreators = getRoomCreatorsForRoomId(mx, spaceItem.roomId);
+ const spacePermissions =
+ spacePowerLevels && getRoomPermissionsAPI(spaceCreators, spacePowerLevels);
const draggingSpace =
draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
const { parentId } = spaceItem;
- const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
+ const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) : undefined;
+ const parentCreators = parentId ? getRoomCreatorsForRoomId(mx, parentId) : undefined;
+ const parentPermissions =
+ parentCreators &&
+ parentPowerLevels &&
+ getRoomPermissionsAPI(parentCreators, parentPowerLevels);
useEffect(() => {
onSpacesFound(Array.from(subspaces.values()));
}, [subspaces, onSpacesFound]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
- if (!canEditSpaceChild(spacePowerLevels)) {
+ if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) {
// hide unknown rooms for normal user
childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
@@ -117,18 +120,22 @@ export const SpaceHierarchy = forwardRef(
closed={closed}
handleClose={handleClose}
getRoom={getRoom}
- canEditChild={canEditSpaceChild(spacePowerLevels)}
+ canEditChild={!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())}
canReorder={
- parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
+ parentPowerLevels && !disabledReorder && parentPermissions
+ ? parentPermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
+ : false
}
options={
parentId &&
parentPowerLevels && (
@@ -151,15 +158,6 @@ export const SpaceHierarchy = forwardRef(
const roomSummary = rooms.get(roomItem.roomId);
const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
- const userPLInRoom = powerLevelAPI.getPowerLevel(
- roomPowerLevels,
- mx.getUserId() ?? undefined
- );
- const canInviteInRoom = powerLevelAPI.canDoAction(
- roomPowerLevels,
- 'invite',
- userPLInRoom
- );
const lastItem = index === childItems.length;
const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
@@ -178,13 +176,18 @@ export const SpaceHierarchy = forwardRef(
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
- canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
+ canReorder={
+ !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
+ !disabledReorder
+ }
options={
}
after={
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
index bc94092b..62ef9c4b 100644
--- a/src/app/features/message-search/SearchResultGroup.tsx
+++ b/src/app/features/message-search/SearchResultGroup.tsx
@@ -39,15 +39,18 @@ import { UserAvatar } from '../../components/user-avatar';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import {
- getTagIconSrc,
- useAccessibleTagColors,
- usePowerLevelTags,
-} from '../../hooks/usePowerLevelTags';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
+import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useTheme } from '../../hooks/useTheme';
import { PowerIcon } from '../../components/power';
import colorMXID from '../../../util/colorMXID';
+import {
+ getPowerTagIconSrc,
+ useAccessiblePowerTagColors,
+ useGetMemberPowerTag,
+} from '../../hooks/useMemberPowerTag';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
type SearchResultGroupProps = {
room: Room;
@@ -76,10 +79,14 @@ export function SearchResultGroup({
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
const powerLevels = usePowerLevels(room);
- const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
- const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+ const creators = useRoomCreators(room);
+
+ const creatorsTag = useRoomCreatorsTag();
+ const powerLevelTags = usePowerLevelTags(room, powerLevels);
+ const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+
const theme = useTheme();
- const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
+ const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
@@ -226,13 +233,12 @@ export function SearchResultGroup({
const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
- const senderPowerLevel = getPowerLevel(event.sender);
- const powerLevelTag = getPowerLevelTag(senderPowerLevel);
- const tagColor = powerLevelTag?.color
- ? accessibleTagColors?.get(powerLevelTag.color)
+ const memberPowerTag = getMemberPowerTag(event.sender);
+ const tagColor = memberPowerTag?.color
+ ? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
- const tagIconSrc = powerLevelTag?.icon
- ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
+ const tagIconSrc = memberPowerTag?.icon
+ ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
@@ -302,8 +308,7 @@ export function SearchResultGroup({
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
- getPowerLevel={getPowerLevel}
- getPowerLevelTag={getPowerLevelTag}
+ getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index bdb81418..ee8b6787 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -27,7 +27,7 @@ import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
-import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../../client/action/notifications';
import { openInviteUser } from '../../../client/action/navigation';
@@ -49,6 +49,8 @@ import {
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
type RoomNavItemMenuProps = {
room: Room;
@@ -61,8 +63,10 @@ const RoomNavItemMenu = forwardRef(
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
- const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
- const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+ const creators = useRoomCreators(room);
+
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const canInvite = permissions.action('invite', mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally();
diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
index 0c3152c0..d9c16c90 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -13,6 +13,8 @@ import {
RoomPublish,
RoomUpgrade,
} from '../../common-settings/general';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
type GeneralProps = {
requestClose: () => void;
@@ -20,6 +22,8 @@ type GeneralProps = {
export function General({ requestClose }: GeneralProps) {
const room = useRoom();
const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
return (
@@ -41,22 +45,22 @@ export function General({ requestClose }: GeneralProps) {
-
+
Options
-
-
-
-
+
+
+
+
Addresses
-
-
+
+
Advance Options
-
+
diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx
index ae3769bf..7572a71b 100644
--- a/src/app/features/room-settings/permissions/Permissions.tsx
+++ b/src/app/features/room-settings/permissions/Permissions.tsx
@@ -2,11 +2,13 @@ import React, { useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
type PermissionsProps = {
requestClose: () => void;
@@ -15,11 +17,12 @@ export function Permissions({ requestClose }: PermissionsProps) {
const mx = useMatrixClient();
const room = useRoom();
const powerLevels = usePowerLevels(room);
- const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
- const canEditPowers = canSendStateEvent(
- StateEvent.PowerLevelTags,
- getPowerLevel(mx.getSafeUserId())
- );
+ const creators = useRoomCreators(room);
+
+ const permissions = useRoomPermissions(creators, powerLevels);
+
+ const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
+ const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
const permissionGroups = usePermissionGroups();
const [powerEditor, setPowerEditor] = useState(false);
@@ -57,7 +60,11 @@ export function Permissions({ requestClose }: PermissionsProps) {
onEdit={canEditPowers ? handleEditPowers : undefined}
permissionGroups={permissionGroups}
/>
-
+
diff --git a/src/app/features/room/MembersDrawer.css.ts b/src/app/features/room/MembersDrawer.css.ts
index a1f4153e..860ceda0 100644
--- a/src/app/features/room/MembersDrawer.css.ts
+++ b/src/app/features/room/MembersDrawer.css.ts
@@ -1,10 +1,8 @@
import { keyframes, style } from '@vanilla-extract/css';
-import { color, config, toRem } from 'folds';
+import { config, toRem } from 'folds';
export const MembersDrawer = style({
width: toRem(266),
- backgroundColor: color.Background.Container,
- color: color.Background.OnContainer,
});
export const MembersDrawerHeader = style({
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index bdb0eb3d..46d2238a 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -26,7 +26,7 @@ import {
TooltipProvider,
config,
} from 'folds';
-import { Room, RoomMember } from 'matrix-js-sdk';
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
@@ -39,7 +39,6 @@ import {
useAsyncSearch,
} from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
-import { usePowerLevelTags, useFlattenPowerLevelTagMembers } from '../../hooks/usePowerLevelTags';
import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
@@ -51,12 +50,116 @@ import { UserAvatar } from '../../components/user-avatar';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
-import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
-import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu';
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+
+type MemberDrawerHeaderProps = {
+ room: Room;
+};
+function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
+ const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+
+ return (
+
+
+
+
+ {`${millify(room.getJoinedMemberCount())} Members`}
+
+
+
+
+ Close
+
+ }
+ >
+ {(triggerRef) => (
+ setPeopleDrawer(false)}
+ >
+
+
+ )}
+
+
+
+
+ );
+}
+
+type MemberItemProps = {
+ mx: MatrixClient;
+ useAuthentication: boolean;
+ room: Room;
+ member: RoomMember;
+ onClick: MouseEventHandler;
+ pressed?: boolean;
+ typing?: boolean;
+};
+function MemberItem({
+ mx,
+ useAuthentication,
+ room,
+ member,
+ onClick,
+ pressed,
+ typing,
+}: MemberItemProps) {
+ const name =
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+ const avatarMxcUrl = member.getMxcAvatarUrl();
+ const avatarUrl = avatarMxcUrl
+ ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
+ : undefined;
+
+ return (
+
+ }
+ />
+
+ }
+ after={
+ typing && (
+
+
+
+ )
+ }
+ >
+
+
+ {name}
+
+
+
+ );
+}
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -80,9 +183,10 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const searchInputRef = useRef(null);
const scrollTopAnchorRef = useRef(null);
const powerLevels = usePowerLevelsContext();
- const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+ const creators = useRoomCreators(room);
+ const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+
const fetchingMembers = members.length < room.getJoinedMemberCount();
- const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const openProfileUserId = useUserRoomProfileState()?.userId;
@@ -91,20 +195,16 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const sortFilterMenu = useMemberSortMenu();
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
- const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
+ const memberPowerSort = useMemberPowerSort(creators);
const typingMembers = useRoomTypingMember(room.roomId);
const filteredMembers = useMemo(
- () =>
- members
- .filter(membershipFilter.filterFn)
- .sort(memberSort.sortFn)
- .sort((a, b) => b.powerLevel - a.powerLevel),
- [members, membershipFilter, memberSort]
+ () => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
+ [members, membershipFilter, memberSort, memberPowerSort]
);
const [result, search, resetSearch] = useAsyncSearch(
@@ -116,11 +216,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const processMembers = result ? result.items : filteredMembers;
- const PLTagOrRoomMember = useFlattenPowerLevelTagMembers(
- processMembers,
- getPowerLevel,
- getPowerLevelTag
- );
+ const PLTagOrRoomMember = useFlattenPowerTagMembers(processMembers, getPowerTag);
const virtualizer = useVirtualizer({
count: PLTagOrRoomMember.length,
@@ -140,9 +236,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
{ wait: 200 }
);
- const getName = (member: RoomMember) =>
- getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
-
const handleMemberClick: MouseEventHandler = (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
@@ -151,38 +244,12 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
};
return (
-
-
-
-
-
- {`${millify(room.getJoinedMemberCount())} Members`}
-
-
-
-
- Close
-
- }
- >
- {(triggerRef) => (
- setPeopleDrawer(false)}
- >
-
-
- )}
-
-
-
-
+
+
@@ -334,60 +401,28 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
);
}
- const member = tagOrMember;
- const name = getName(member);
- const avatarMxcUrl = member.getMxcAvatarUrl();
- const avatarUrl = avatarMxcUrl
- ? mx.mxcUrlToHttp(
- avatarMxcUrl,
- 100,
- 100,
- 'crop',
- undefined,
- false,
- useAuthentication
- )
- : undefined;
-
return (
-
- }
- />
-
- }
- after={
- typingMembers.find((receipt) => receipt.userId === member.userId) && (
-
-
-
- )
- }
+ data-index={vItem.index}
+ key={`${room.roomId}-${tagOrMember.userId}`}
+ ref={virtualizer.measureElement}
>
-
-
- {name}
-
-
-
+ receipt.userId === tagOrMember.userId
+ )}
+ />
+
);
})}