Support room version 12 (#2399)
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run

* WIP - support room version 12

* add room creators hook

* revert changes from powerlevels

* improve use room creators hook

* add hook to get dm users

* add options to add creators in create room/space

* add member item component in member drawer

* remove unused import

* extract member drawer header component

* get room creators as set only if room version support them

* add room permissions hook

* support room v12 creators power

* make predecessor event id optional

* add info about founders in permissions

* allow to create infinite powers to room creators

* allow everyone with permission to create infinite power

* handle additional creators in room upgrade

* add option to follow space tombstone
This commit is contained in:
Ajay Bura 2025-08-12 19:42:30 +05:30 committed by GitHub
parent 4d1ae4eafd
commit f82cfead46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1717 additions and 783 deletions

View file

@ -27,8 +27,10 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
const EDITOR_INTENT_SPACE_COUNT = 2;
@ -244,8 +246,10 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
const [editContent, setEditContent] = useState<object>();
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEdit = permissions.stateEvent(type, mx.getSafeUserId());
const eventJSONStr = useMemo(() => {
if (!stateEvent) return '';

View file

@ -33,11 +33,13 @@ import { SequenceCardStyle } from '../styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { suffixRename } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
type CreatePackTileProps = {
packs: ImagePack[];
@ -146,8 +148,10 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
const alive = useAlive();
const powerLevels = usePowerLevels(room);
const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEdit = permissions.stateEvent(StateEvent.PoniesRoomEmotes, mx.getSafeUserId());
const unfilteredPacks = useRoomImagePacks(room);
const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);

View file

@ -15,7 +15,6 @@ import {
toRem,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
@ -33,19 +32,19 @@ import { getIdServer } from '../../../../util/matrixUtil';
import { replaceSpaceWithDash } from '../../../utils/common';
import { useAlive } from '../../../hooks/useAlive';
import { StateEvent } from '../../../../types/matrix/room';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
type RoomPublishedAddressesProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
const canEditCanonical = permissions.stateEvent(
StateEvent.RoomCanonicalAlias,
userPowerLevel
mx.getSafeUserId()
);
const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
@ -360,14 +359,13 @@ function LocalAddressesList({
);
}
export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissionsAPI }) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
const canEditCanonical = permissions.stateEvent(
StateEvent.RoomCanonicalAlias,
userPowerLevel
mx.getSafeUserId()
);
const [expand, setExpand] = useState(false);

View file

@ -21,28 +21,24 @@ import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
type RoomEncryptionProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEnable = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomEncryption,
userPowerLevel
);
const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId());
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
algorithm: string;
}>();

View file

@ -18,13 +18,13 @@ import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
const useVisibilityStr = () =>
useMemo(
@ -49,17 +49,13 @@ const useVisibilityMenu = () =>
);
type RoomHistoryVisibilityProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEdit = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomHistoryVisibility,
userPowerLevel
);
const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId());
const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
const historyVisibility: HistoryVisibility =

View file

@ -3,7 +3,6 @@ import { color, Text } from 'folds';
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { useAtomValue } from 'jotai';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import {
ExtendedJoinRules,
JoinRulesSwitcher,
@ -32,6 +31,7 @@ import {
knockSupported,
restrictedSupported,
} from '../../../utils/matrix';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
type RestrictedRoomAllowContent = {
room_id: string;
@ -39,9 +39,9 @@ type RestrictedRoomAllowContent = {
};
type RoomJoinRulesProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
const mx = useMatrixClient();
const room = useRoom();
const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
@ -53,12 +53,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents);
const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope);
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEdit = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomHistoryVisibility,
userPowerLevel
);
const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId());
const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();

View file

@ -32,7 +32,6 @@ import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
@ -40,6 +39,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
type RoomProfileEditProps = {
canEditAvatar: boolean;
@ -261,24 +261,22 @@ export function RoomProfileEdit({
}
type RoomProfileProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomProfile({ powerLevels }: RoomProfileProps) {
export function RoomProfile({ permissions }: RoomProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const directs = useAtomValue(mDirectAtom);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const userPowerLevel = getPowerLevel(mx.getSafeUserId());
const avatar = useRoomAvatar(room, directs.has(room.roomId));
const name = useRoomName(room);
const topic = useRoomTopic(room);
const joinRule = useRoomJoinRule(room);
const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
const canEditTopic = permissions.stateEvent(StateEvent.RoomTopic, mx.getSafeUserId());
const canEdit = canEditAvatar || canEditName || canEditTopic;
const avatarUrl = avatar

View file

@ -8,23 +8,22 @@ import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { ExtendedJoinRules } from '../../../components/JoinRulesSwitcher';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
type RoomPublishProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
};
export function RoomPublish({ powerLevels }: RoomPublishProps) {
export function RoomPublish({ permissions }: RoomPublishProps) {
const mx = useMatrixClient();
const room = useRoom();
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEditCanonical = powerLevelAPI.canSendStateEvent(
powerLevels,
const canEditCanonical = permissions.stateEvent(
StateEvent.RoomCanonicalAlias,
userPowerLevel
mx.getSafeUserId()
);
const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();

View file

@ -1,4 +1,4 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
Button,
color,
@ -14,54 +14,172 @@ import {
IconButton,
Icon,
Icons,
Input,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { MatrixError } from 'matrix-js-sdk';
import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
import { MatrixError, Method } from 'matrix-js-sdk';
import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { IRoomCreateContent, StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import {
AdditionalCreatorInput,
RoomVersionSelector,
useAdditionalCreators,
} from '../../../components/create-room';
import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const creators = useRoomCreators(room);
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
useEffect(() => {
// capabilities load async
selectRoomVersion(roomVersions?.default ?? '1');
}, [roomVersions?.default]);
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
useAdditionalCreators(Array.from(creators));
const [upgradeState, upgrade] = useAsyncCallback(
useCallback(
async (version: string, newAdditionalCreators?: string[]) => {
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
new_version: version,
additional_creators: newAdditionalCreators,
});
},
[mx, room]
)
);
const upgrading = upgradeState.status === AsyncStatus.Loading;
const handleUpgradeRoom = () => {
const version = selectedRoomVersion;
upgrade(version, allowAdditionalCreators ? additionalCreators : undefined).then(() => {
if (alive()) {
requestClose();
}
});
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={selectRoomVersion}
disabled={upgrading}
/>
{allowAdditionalCreators && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<AdditionalCreatorInput
additionalCreators={additionalCreators}
onSelect={addAdditionalCreator}
onRemove={removeAdditionalCreator}
disabled={upgrading}
/>
</SequenceCard>
)}
</Box>
{upgradeState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
<Button
onClick={handleUpgradeRoom}
variant="Secondary"
disabled={upgrading}
before={upgrading && <Spinner size="200" variant="Secondary" fill="Solid" />}
>
<Text size="B400">{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type RoomUpgradeProps = {
powerLevels: IPowerLevels;
permissions: RoomPermissionsAPI;
requestClose: () => void;
};
export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) {
const mx = useMatrixClient();
const room = useRoom();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const createContent = useStateEvent(
room,
StateEvent.RoomCreate
)?.getContent<RoomCreateEventContent>();
const roomVersion = createContent?.room_version ?? 1;
)?.getContent<IRoomCreateContent>();
const roomVersion = createContent?.room_version ?? '1';
const predecessorRoomId = createContent?.predecessor?.room_id;
const capabilities = useCapabilities();
const defaultRoomVersion = capabilities['m.room_versions']?.default;
const tombstoneContent = useStateEvent(
room,
StateEvent.RoomTombstone
)?.getContent<RoomTombstoneEventContent>();
const replacementRoom = tombstoneContent?.replacement_room;
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canUpgrade = powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.RoomTombstone,
userPowerLevel
);
const canUpgrade = permissions.stateEvent(StateEvent.RoomTombstone, mx.getSafeUserId());
const handleOpenRoom = () => {
if (replacementRoom) {
@ -85,31 +203,8 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
}
};
const [upgradeState, upgrade] = useAsyncCallback(
useCallback(
async (version: string) => {
await mx.upgradeRoom(room.roomId, version);
},
[mx, room]
)
);
const upgrading = upgradeState.status === AsyncStatus.Loading;
const [prompt, setPrompt] = useState(false);
const handleSubmitUpgrade: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const versionInput = target?.versionInput as HTMLInputElement | undefined;
const version = versionInput?.value.trim();
if (!version) return;
upgrade(version);
setPrompt(false);
};
return (
<SequenceCard
className={SequenceCardStyle}
@ -123,7 +218,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
replacementRoom
? tombstoneContent.body ||
`This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
: `Current room version: ${roomVersion}.`
: `Current version: ${roomVersion}.`
}
after={
<Box alignItems="Center" gap="200">
@ -155,8 +250,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
variant="Secondary"
fill="Solid"
radii="300"
disabled={upgrading || !canUpgrade}
before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={!canUpgrade}
onClick={() => setPrompt(true)}
>
<Text size="B300">Upgrade</Text>
@ -165,63 +259,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
</Box>
}
>
{upgradeState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Version</Text>
<Input
defaultValue={defaultRoomVersion}
name="versionInput"
variant="Background"
required
/>
</Box>
<Button type="submit" variant="Secondary">
<Text size="B400">
{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{prompt && <RoomUpgradeDialog requestClose={() => setPrompt(false)} />}
</SettingTile>
</SequenceCard>
);

View file

@ -27,11 +27,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import {
useFlattenPowerLevelTagMembers,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { VirtualTile } from '../../../components/virtualizer';
import { MemberTile } from '../../../components/member-tile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@ -45,7 +41,7 @@ import {
} from '../../../hooks/useAsyncSearch';
import { getMemberSearchStr } from '../../../utils/room';
import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
import { UseStateProvider } from '../../../components/UseStateProvider';
@ -57,6 +53,8 @@ import {
useUserRoomProfileState,
} from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -86,13 +84,14 @@ export function Members({ requestClose }: MembersProps) {
const space = useSpaceOptionally();
const powerLevels = usePowerLevels(room);
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
const memberPowerSort = useMemberPowerSort(creators);
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@ -103,8 +102,8 @@ export function Members({ requestClose }: MembersProps) {
Array.from(members)
.filter(membershipFilter.filterFn)
.sort(memberSort.sortFn)
.sort((a, b) => b.powerLevel - a.powerLevel),
[members, membershipFilter, memberSort]
.sort(memberPowerSort),
[members, membershipFilter, memberSort, memberPowerSort]
);
const [result, search, resetSearch] = useAsyncSearch(
@ -114,11 +113,7 @@ export function Members({ requestClose }: MembersProps) {
);
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
const flattenTagMembers = useFlattenPowerLevelTagMembers(
result?.items ?? sortedMembers,
getPowerLevel,
getPowerLevelTag
);
const flattenTagMembers = useFlattenPowerTagMembers(result?.items ?? sortedMembers, getPowerTag);
const virtualizer = useVirtualizer({
count: flattenTagMembers.length,

View file

@ -10,10 +10,9 @@ import {
getPermissionPower,
IPowerLevels,
PermissionLocation,
usePowerLevelsAPI,
} from '../../../hooks/usePowerLevels';
import { PermissionGroup } from './types';
import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { getPowerLevelTag, getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useRoom } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
@ -26,19 +25,20 @@ const USER_DEFAULT_LOCATION: PermissionLocation = {
};
type PermissionGroupsProps = {
canEdit: boolean;
powerLevels: IPowerLevels;
permissionGroups: PermissionGroup[];
};
export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGroupsProps) {
export function PermissionGroups({
powerLevels,
permissionGroups,
canEdit,
}: PermissionGroupsProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canChangePermission = canSendStateEvent(
StateEvent.RoomPowerLevels,
getPowerLevel(mx.getSafeUserId())
);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
@ -82,6 +82,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr
permissionUpdate.forEach((power, location) =>
applyPermissionPower(draftPowerLevels, location, power)
);
return draftPowerLevels;
});
await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
@ -108,7 +109,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr
const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
const value = powerUpdate ?? power;
const tag = getPowerLevelTag(value);
const tag = getPowerLevelTag(powerLevelTags, value);
const powerChanges = value !== power;
return (
@ -136,14 +137,14 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr
fill="Soft"
radii="Pill"
aria-selected={opened}
disabled={!canChangePermission || applyingChanges}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
canEdit && (
<Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
)
}
@ -173,7 +174,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr
const powerUpdate = permissionUpdate.get(item.location);
const value = powerUpdate ?? power;
const tag = getPowerLevelTag(value);
const tag = getPowerLevelTag(powerLevelTags, value);
const powerChanges = value !== power;
return (
@ -200,14 +201,14 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr
fill="Soft"
radii="Pill"
aria-selected={opened}
disabled={!canChangePermission || applyingChanges}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
canEdit && (
<Icon
size="50"
src={opened ? Icons.ChevronTop : Icons.ChevronBottom}

View file

@ -16,7 +16,7 @@ import {
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { SettingTile } from '../../../components/setting-tile';
import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
@ -25,6 +25,9 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { stopPropagation } from '../../../utils/keyboard';
import { PermissionGroup } from './types';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
type PeekPermissionsProps = {
powerLevels: IPowerLevels;
@ -108,10 +111,43 @@ export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const creatorTagIconSrc =
creatorsTag.icon && getPowerTagIconSrc(mx, useAuthentication, creatorsTag.icon);
return (
<Box direction="Column" gap="100">
{creators.size > 0 && (
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title="Founders"
description="Founding members has all permissions and can only be changed during upgrade."
/>
<SettingTile>
<Box gap="200" wrap="Wrap">
<Chip
disabled
variant="Secondary"
radii="300"
before={<PowerColorBadge color={creatorsTag.color} />}
after={creatorTagIconSrc && <PowerIcon size="50" iconSrc={creatorTagIconSrc} />}
>
<Text size="T300" truncate>
<b>{creatorsTag.name}</b>
</Text>
</Chip>
</Box>
</SettingTile>
</SequenceCard>
)}
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
@ -142,7 +178,7 @@ export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
<Box gap="200" wrap="Wrap">
{getPowers(powerLevelTags).map((power) => {
const tag = powerLevelTags[power];
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
return (
<PeekPermissions

View file

@ -27,10 +27,7 @@ import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import {
getPowers,
getTagIconSrc,
getUsedPowers,
PowerLevelTag,
PowerLevelTagIcon,
PowerLevelTags,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
@ -47,15 +44,17 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { StateEvent } from '../../../../types/matrix/room';
import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room';
import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix';
type EditPowerProps = {
maxPower: number;
power?: number;
tag?: PowerLevelTag;
onSave: (power: number, tag: PowerLevelTag) => void;
tag?: MemberPowerTag;
onSave: (power: number, tag: MemberPowerTag) => void;
onClose: () => void;
};
function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
@ -63,6 +62,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const room = useRoom();
const roomToParents = useAtomValue(roomToParentsAtom);
const useAuthentication = useMediaAuthentication();
const supportCreators = creatorsSupported(room.getVersion());
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
@ -70,9 +70,9 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const pickFile = useFilePicker(setIconFile, false);
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
const uploadingIcon = iconFile && !tagIcon;
const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon);
const tagIconSrc = tagIcon && getPowerTagIconSrc(mx, useAuthentication, tagIcon);
const iconUploadAtom = useMemo(() => {
if (iconFile) return createUploadAtom(iconFile);
@ -101,11 +101,11 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const tagPower = parseInt(powerInput.value, 10);
if (Number.isNaN(tagPower)) return;
if (tagPower > maxPower) return;
const tagName = nameInput.value.trim();
if (!tagName) return;
const editedTag: PowerLevelTag = {
const editedTag: MemberPowerTag = {
name: tagName,
color: tagColor,
icon: tagIcon,
@ -165,7 +165,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
radii="300"
type="number"
placeholder="75"
max={maxPower}
max={supportCreators ? undefined : maxPower}
outlined={typeof power === 'number'}
readOnly={typeof power === 'number'}
required
@ -298,7 +298,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
return [up, Math.max(...Array.from(up))];
}, [powerLevels]);
const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const [editedPowerTags, setEditedPowerTags] = useState<PowerLevelTags>();
const [deleted, setDeleted] = useState<Set<number>>(new Set());
@ -317,7 +317,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
}, []);
const handleSaveTag = useCallback(
(power: number, tag: PowerLevelTag) => {
(power: number, tag: MemberPowerTag) => {
setEditedPowerTags((tags) => {
const editedTags = { ...(tags ?? powerLevelTags) };
editedTags[power] = tag;
@ -419,7 +419,8 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
</SequenceCard>
{getPowers(powerTags).map((power) => {
const tag = powerTags[power];
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
const tagIconSrc =
tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
return (
<SequenceCard