Support room version 12 (#2399)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 12 Aug 2025 14:12:30 +0000 (19:42 +0530)
committerGitHub <noreply@github.com>
Tue, 12 Aug 2025 14:12:30 +0000 (00:12 +1000)
* 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

58 files changed:
src/app/components/create-room/AdditionalCreatorInput.tsx [new file with mode: 0644]
src/app/components/create-room/RoomVersionSelector.tsx
src/app/components/create-room/index.ts
src/app/components/create-room/utils.ts
src/app/components/image-pack-view/RoomImagePack.tsx
src/app/components/message/Reply.tsx
src/app/components/room-intro/RoomIntro.tsx
src/app/components/user-profile/CreatorChip.tsx [new file with mode: 0644]
src/app/components/user-profile/PowerChip.tsx
src/app/components/user-profile/UserRoomProfile.tsx
src/app/features/common-settings/developer-tools/StateEventEditor.tsx
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
src/app/features/common-settings/general/RoomAddress.tsx
src/app/features/common-settings/general/RoomEncryption.tsx
src/app/features/common-settings/general/RoomHistoryVisibility.tsx
src/app/features/common-settings/general/RoomJoinRules.tsx
src/app/features/common-settings/general/RoomProfile.tsx
src/app/features/common-settings/general/RoomPublish.tsx
src/app/features/common-settings/general/RoomUpgrade.tsx
src/app/features/common-settings/members/Members.tsx
src/app/features/common-settings/permissions/PermissionGroups.tsx
src/app/features/common-settings/permissions/Powers.tsx
src/app/features/common-settings/permissions/PowersEditor.tsx
src/app/features/create-room/CreateRoom.tsx
src/app/features/create-space/CreateSpace.tsx
src/app/features/lobby/HierarchyItemMenu.tsx
src/app/features/lobby/Lobby.tsx
src/app/features/lobby/LobbyHeader.tsx
src/app/features/lobby/SpaceHierarchy.tsx
src/app/features/message-search/SearchResultGroup.tsx
src/app/features/room-nav/RoomNavItem.tsx
src/app/features/room-settings/general/General.tsx
src/app/features/room-settings/permissions/Permissions.tsx
src/app/features/room/MembersDrawer.css.ts
src/app/features/room/MembersDrawer.tsx
src/app/features/room/RoomInput.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomView.tsx
src/app/features/room/RoomViewHeader.tsx
src/app/features/room/message/Message.tsx
src/app/features/room/room-pin-menu/RoomPinMenu.tsx
src/app/features/space-settings/general/General.tsx
src/app/features/space-settings/permissions/Permissions.tsx
src/app/hooks/useDirectUsers.ts [new file with mode: 0644]
src/app/hooks/useMemberPowerCompare.ts [new file with mode: 0644]
src/app/hooks/useMemberPowerTag.ts [new file with mode: 0644]
src/app/hooks/useMemberSort.ts
src/app/hooks/usePowerLevelTags.ts
src/app/hooks/usePowerLevels.ts
src/app/hooks/useRoomCreators.ts [new file with mode: 0644]
src/app/hooks/useRoomCreatorsTag.ts [new file with mode: 0644]
src/app/hooks/useRoomPermissions.ts [new file with mode: 0644]
src/app/pages/client/inbox/Notifications.tsx
src/app/pages/client/sidebar/SpaceTabs.tsx
src/app/pages/client/space/Space.tsx
src/app/utils/matrix.ts
src/types/matrix/accountData.ts
src/types/matrix/room.ts

diff --git a/src/app/components/create-room/AdditionalCreatorInput.tsx b/src/app/components/create-room/AdditionalCreatorInput.tsx
new file mode 100644 (file)
index 0000000..51334b4
--- /dev/null
@@ -0,0 +1,306 @@
+import {
+  Box,
+  Button,
+  Chip,
+  config,
+  Icon,
+  Icons,
+  Input,
+  Line,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Scroll,
+  Text,
+  toRem,
+} from 'folds';
+import { isKeyHotkey } from 'is-hotkey';
+import FocusTrap from 'focus-trap-react';
+import React, {
+  ChangeEventHandler,
+  KeyboardEventHandler,
+  MouseEventHandler,
+  useMemo,
+  useState,
+} from 'react';
+import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
+import { useDirectUsers } from '../../hooks/useDirectUsers';
+import { SettingTile } from '../setting-tile';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { stopPropagation } from '../../utils/keyboard';
+import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
+import { findAndReplace } from '../../utils/findAndReplace';
+import { highlightText } from '../../styles/CustomHtml.css';
+import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
+
+export const useAdditionalCreators = (defaultCreators?: string[]) => {
+  const mx = useMatrixClient();
+  const [additionalCreators, setAdditionalCreators] = useState<string[]>(
+    () => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
+  );
+
+  const addAdditionalCreator = (userId: string) => {
+    if (userId === mx.getSafeUserId()) return;
+
+    setAdditionalCreators((creators) => {
+      const creatorsSet = new Set(creators);
+      creatorsSet.add(userId);
+      return Array.from(creatorsSet);
+    });
+  };
+
+  const removeAdditionalCreator = (userId: string) => {
+    setAdditionalCreators((creators) => {
+      const creatorsSet = new Set(creators);
+      creatorsSet.delete(userId);
+      return Array.from(creatorsSet);
+    });
+  };
+
+  return {
+    additionalCreators,
+    addAdditionalCreator,
+    removeAdditionalCreator,
+  };
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  limit: 1000,
+  matchOptions: {
+    contain: true,
+  },
+};
+const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
+
+type AdditionalCreatorInputProps = {
+  additionalCreators: string[];
+  onSelect: (userId: string) => void;
+  onRemove: (userId: string) => void;
+  disabled?: boolean;
+};
+export function AdditionalCreatorInput({
+  additionalCreators,
+  onSelect,
+  onRemove,
+  disabled,
+}: AdditionalCreatorInputProps) {
+  const mx = useMatrixClient();
+  const [menuCords, setMenuCords] = useState<RectCords>();
+  const directUsers = useDirectUsers();
+
+  const [validUserId, setValidUserId] = useState<string>();
+  const filteredUsers = useMemo(
+    () => directUsers.filter((userId) => !additionalCreators.includes(userId)),
+    [directUsers, additionalCreators]
+  );
+  const [result, search, resetSearch] = useAsyncSearch(
+    filteredUsers,
+    getUserIdString,
+    SEARCH_OPTIONS
+  );
+  const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined;
+
+  const suggestionUsers = result
+    ? result.items
+    : filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1));
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuCords(evt.currentTarget.getBoundingClientRect());
+  };
+  const handleCloseMenu = () => {
+    setMenuCords(undefined);
+    setValidUserId(undefined);
+    resetSearch();
+  };
+
+  const handleCreatorChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const creatorInput = evt.currentTarget;
+    const creator = creatorInput.value.trim();
+    if (isUserId(creator)) {
+      setValidUserId(creator);
+    } else {
+      setValidUserId(undefined);
+      const term =
+        getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator);
+      if (term) {
+        search(term);
+      } else {
+        resetSearch();
+      }
+    }
+  };
+
+  const handleSelectUserId = (userId?: string) => {
+    if (userId && isUserId(userId)) {
+      onSelect(userId);
+      handleCloseMenu();
+    }
+  };
+
+  const handleCreatorKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+    if (isKeyHotkey('enter', evt)) {
+      evt.preventDefault();
+      const creator = evt.currentTarget.value.trim();
+      handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]);
+    }
+  };
+
+  const handleEnterClick = () => {
+    handleSelectUserId(validUserId);
+  };
+
+  return (
+    <SettingTile
+      title="Founders"
+      description="Special privileged users can be assigned during creation. These users have elevated control and can only be modified during a upgrade."
+    >
+      <Box shrink="No" direction="Column" gap="100">
+        <Box gap="200" wrap="Wrap">
+          <Chip type="button" variant="Primary" radii="Pill" outlined>
+            <Text size="B300">{mx.getSafeUserId()}</Text>
+          </Chip>
+          {additionalCreators.map((creator) => (
+            <Chip
+              type="button"
+              key={creator}
+              variant="Secondary"
+              radii="Pill"
+              after={<Icon size="50" src={Icons.Cross} />}
+              onClick={() => onRemove(creator)}
+              disabled={disabled}
+            >
+              <Text size="B300">{creator}</Text>
+            </Chip>
+          ))}
+          <PopOut
+            anchor={menuCords}
+            position="Bottom"
+            align="Center"
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  onDeactivate: handleCloseMenu,
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                  escapeDeactivates: stopPropagation,
+                }}
+              >
+                <Menu
+                  style={{
+                    width: '100vw',
+                    maxWidth: toRem(300),
+                    height: toRem(250),
+                    display: 'flex',
+                  }}
+                >
+                  <Box grow="Yes" direction="Column">
+                    <Box shrink="No" gap="100" style={{ padding: config.space.S100 }}>
+                      <Box grow="Yes" direction="Column" gap="100">
+                        <Input
+                          size="400"
+                          variant="Background"
+                          radii="300"
+                          outlined
+                          placeholder="@john:server"
+                          onChange={handleCreatorChange}
+                          onKeyDown={handleCreatorKeyDown}
+                        />
+                      </Box>
+                      <Button
+                        type="button"
+                        variant="Success"
+                        radii="300"
+                        onClick={handleEnterClick}
+                        disabled={!validUserId}
+                      >
+                        <Text size="B400">Enter</Text>
+                      </Button>
+                    </Box>
+                    <Line size="300" />
+                    <Box grow="Yes" direction="Column">
+                      {!validUserId && suggestionUsers.length > 0 ? (
+                        <Scroll size="300" hideTrack>
+                          <Box
+                            grow="Yes"
+                            direction="Column"
+                            gap="100"
+                            style={{ padding: config.space.S200, paddingRight: 0 }}
+                          >
+                            {suggestionUsers.map((userId) => (
+                              <MenuItem
+                                key={userId}
+                                size="300"
+                                variant="Surface"
+                                radii="300"
+                                onClick={() => handleSelectUserId(userId)}
+                                after={
+                                  <Text size="T200" truncate>
+                                    {getMxIdServer(userId)}
+                                  </Text>
+                                }
+                              >
+                                <Box grow="Yes">
+                                  <Text size="T200" truncate>
+                                    <b>
+                                      {queryHighlighRegex
+                                        ? findAndReplace(
+                                            getMxIdLocalPart(userId) ?? userId,
+                                            queryHighlighRegex,
+                                            (match, pushIndex) => (
+                                              <span
+                                                key={`highlight-${pushIndex}`}
+                                                className={highlightText}
+                                              >
+                                                {match[0]}
+                                              </span>
+                                            ),
+                                            (txt) => txt
+                                          )
+                                        : getMxIdLocalPart(userId)}
+                                    </b>
+                                  </Text>
+                                </Box>
+                              </MenuItem>
+                            ))}
+                          </Box>
+                        </Scroll>
+                      ) : (
+                        <Box
+                          grow="Yes"
+                          alignItems="Center"
+                          justifyContent="Center"
+                          direction="Column"
+                          gap="100"
+                        >
+                          <Text size="H6" align="Center">
+                            No Suggestions
+                          </Text>
+                          <Text size="T200" align="Center">
+                            Please provide the user ID and hit Enter.
+                          </Text>
+                        </Box>
+                      )}
+                    </Box>
+                  </Box>
+                </Menu>
+              </FocusTrap>
+            }
+          >
+            <Chip
+              type="button"
+              variant="Secondary"
+              radii="Pill"
+              onClick={handleOpenMenu}
+              aria-pressed={!!menuCords}
+              disabled={disabled}
+            >
+              <Icon size="50" src={Icons.Plus} />
+            </Chip>
+          </PopOut>
+        </Box>
+      </Box>
+    </SettingTile>
+  );
+}
index 281f520a01d874c48a9e4be8b265d49a2036a0b2..219ded0cb0748adcd70e8bd1369d698f1ee6ebdb 100644 (file)
@@ -47,7 +47,7 @@ export function RoomVersionSelector({
       gap="500"
     >
       <SettingTile
-        title="Room Version"
+        title="Version"
         after={
           <PopOut
             anchor={menuCords}
index ffca558d4a7d4f846b4da3c6785918a464a7a110..ffd9cb3d33593080c42386e6ca95a19b129477dd 100644 (file)
@@ -2,3 +2,4 @@ export * from './CreateRoomKindSelector';
 export * from './CreateRoomAliasInput';
 export * from './RoomVersionSelector';
 export * from './utils';
+export * from './AdditionalCreatorInput';
index 9abff4ff5a46557dac152fc3640713356173c0f8..a0ca7488aec1721f9272f0292284b86a03c4751d 100644 (file)
@@ -14,7 +14,8 @@ import { getMxIdServer } from '../../utils/matrix';
 
 export const createRoomCreationContent = (
   type: RoomType | undefined,
-  allowFederation: boolean
+  allowFederation: boolean,
+  additionalCreators: string[] | undefined
 ): object => {
   const content: Record<string, any> = {};
   if (typeof type === 'string') {
@@ -23,6 +24,9 @@ export const createRoomCreationContent = (
   if (allowFederation === false) {
     content['m.federate'] = false;
   }
+  if (Array.isArray(additionalCreators)) {
+    content.additional_creators = additionalCreators;
+  }
 
   return content;
 };
@@ -89,6 +93,7 @@ export type CreateRoomData = {
   encryption?: boolean;
   knock: boolean;
   allowFederation: boolean;
+  additionalCreators?: string[];
 };
 export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
   const initialState: ICreateRoomStateEvent[] = [];
@@ -108,7 +113,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
     name: data.name,
     topic: data.topic,
     room_alias_name: data.aliasLocalPart,
-    creation_content: createRoomCreationContent(data.type, data.allowFederation),
+    creation_content: createRoomCreationContent(
+      data.type,
+      data.allowFederation,
+      data.additionalCreators
+    ),
     initial_state: initialState,
   };
 
index 9dd45c1f3c1cde62a084883b6c08551409be6a8b..92b4ff218ba9f6b7bd098e3f9b66e409e1ab90e0 100644 (file)
@@ -1,12 +1,14 @@
 import React, { useCallback, useMemo } from 'react';
 import { Room } from 'matrix-js-sdk';
-import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { ImagePackContent } from './ImagePackContent';
 import { ImagePack, PackContent } from '../../plugins/custom-emoji';
 import { StateEvent } from '../../../types/matrix/room';
 import { useRoomImagePack } from '../../hooks/useImagePacks';
 import { randomStr } from '../../utils/common';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
 
 type RoomImagePackProps = {
   room: Room;
@@ -17,9 +19,10 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
   const mx = useMatrixClient();
   const userId = mx.getUserId()!;
   const powerLevels = usePowerLevels(room);
+  const creators = useRoomCreators(room);
 
-  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
-  const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId));
+  const permissions = useRoomPermissions(creators, powerLevels);
+  const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId);
 
   const fallbackPack = useMemo(() => {
     const fakePackId = randomStr(4);
index dc92bf833875010355131f043aa19a4777bbbcdc..57bf2af99a96514ac94097556367734938e3e128 100644 (file)
@@ -10,8 +10,8 @@ import * as css from './Reply.css';
 import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
 import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 import { useRoomEvent } from '../../hooks/useRoomEvent';
-import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
 import colorMXID from '../../../util/colorMXID';
+import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
 
 type ReplyLayoutProps = {
   userColor?: string;
@@ -57,8 +57,7 @@ type ReplyProps = {
   replyEventId: string;
   threadRootId?: string | undefined;
   onClick?: MouseEventHandler | undefined;
-  getPowerLevel?: (userId: string) => number;
-  getPowerLevelTag?: GetPowerLevelTag;
+  getMemberPowerTag?: GetMemberPowerTag;
   accessibleTagColors?: Map<string, string>;
   legacyUsernameColor?: boolean;
 };
@@ -71,8 +70,7 @@ export const Reply = as<'div', ReplyProps>(
       replyEventId,
       threadRootId,
       onClick,
-      getPowerLevel,
-      getPowerLevelTag,
+      getMemberPowerTag,
       accessibleTagColors,
       legacyUsernameColor,
       ...props
@@ -88,8 +86,7 @@ export const Reply = as<'div', ReplyProps>(
 
     const { body } = replyEvent?.getContent() ?? {};
     const sender = replyEvent?.getSender();
-    const senderPL = sender && getPowerLevel?.(sender);
-    const powerTag = typeof senderPL === 'number' ? getPowerLevelTag?.(senderPL) : undefined;
+    const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
     const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
 
     const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
index b02d9f5a902968b1d448c0cdae6a1d5138745f52..c388efd460f41aa0dab40efa2fd7507dfe60e214 100644 (file)
@@ -87,7 +87,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
           {typeof prevRoomId === 'string' &&
             (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
               <Button
-                onClick={() => navigateRoom(prevRoomId)}
+                onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
                 variant="Success"
                 size="300"
                 fill="Soft"
diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx
new file mode 100644 (file)
index 0000000..f59d2ae
--- /dev/null
@@ -0,0 +1,101 @@
+import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
+import React, { MouseEventHandler, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import { isKeyHotkey } from 'is-hotkey';
+import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
+import { PowerColorBadge, PowerIcon } from '../power';
+import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { stopPropagation } from '../../utils/keyboard';
+import { useRoom } from '../../hooks/useRoom';
+import { useSpaceOptionally } from '../../hooks/useSpace';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
+import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
+import { SpaceSettingsPage } from '../../state/spaceSettings';
+import { RoomSettingsPage } from '../../state/roomSettings';
+
+export function CreatorChip() {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const room = useRoom();
+  const space = useSpaceOptionally();
+  const openRoomSettings = useOpenRoomSettings();
+  const openSpaceSettings = useOpenSpaceSettings();
+
+  const [cords, setCords] = useState<RectCords>();
+  const tag = useRoomCreatorsTag();
+  const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
+
+  const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const close = () => setCords(undefined);
+
+  return (
+    <PopOut
+      anchor={cords}
+      position="Bottom"
+      align="Start"
+      offset={4}
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: close,
+            clickOutsideDeactivates: true,
+            escapeDeactivates: stopPropagation,
+            isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+            isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+          }}
+        >
+          <Menu>
+            <div style={{ padding: config.space.S100 }}>
+              <MenuItem
+                variant="Surface"
+                fill="None"
+                size="300"
+                radii="300"
+                onClick={() => {
+                  if (room.isSpaceRoom()) {
+                    openSpaceSettings(
+                      room.roomId,
+                      space?.roomId,
+                      SpaceSettingsPage.PermissionsPage
+                    );
+                  } else {
+                    openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
+                  }
+                  close();
+                }}
+              >
+                <Text size="B300">Manage Powers</Text>
+              </MenuItem>
+            </div>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        variant="Success"
+        outlined
+        radii="Pill"
+        before={
+          cords ? (
+            <Icon size="50" src={Icons.ChevronBottom} />
+          ) : (
+            <PowerColorBadge color={tag.color} />
+          )
+        }
+        after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
+        onClick={open}
+        aria-pressed={!!cords}
+      >
+        <Text size="B300" truncate>
+          {tag.name}
+        </Text>
+      </Chip>
+    </PopOut>
+  );
+}
index 78f539a17b2c820003abe3f76ff508ab4d23bbaa..a67583003426bd803cd1f79079b649f15414f019 100644 (file)
@@ -26,8 +26,8 @@ import { isKeyHotkey } from 'is-hotkey';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { PowerColorBadge, PowerIcon } from '../power';
-import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
+import { useGetMemberPowerLevel, usePowerLevels } from '../../hooks/usePowerLevels';
+import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
 import { stopPropagation } from '../../utils/keyboard';
 import { StateEvent } from '../../../types/matrix/room';
 import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
@@ -39,6 +39,10 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
 import { SpaceSettingsPage } from '../../state/spaceSettings';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { BreakWord } from '../../styles/Text.css';
+import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
 
 type SelfDemoteAlertProps = {
   power: number;
@@ -149,16 +153,22 @@ export function PowerChip({ userId }: { userId: string }) {
   const openSpaceSettings = useOpenSpaceSettings();
 
   const powerLevels = usePowerLevels(room);
-  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
-  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
-  const myPower = getPowerLevel(mx.getSafeUserId());
-  const userPower = getPowerLevel(userId);
+  const creators = useRoomCreators(room);
+
+  const permissions = useRoomPermissions(creators, powerLevels);
+  const getMemberPowerLevel = useGetMemberPowerLevel(powerLevels);
+  const { hasMorePower } = useMemberPowerCompare(creators, powerLevels);
+
+  const powerLevelTags = usePowerLevelTags(room, powerLevels);
+  const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+
+  const myUserId = mx.getSafeUserId();
   const canChangePowers =
-    canSendStateEvent(StateEvent.RoomPowerLevels, myPower) &&
-    (mx.getSafeUserId() === userId ? true : myPower > userPower);
+    permissions.stateEvent(StateEvent.RoomPowerLevels, myUserId) &&
+    (myUserId === userId ? true : hasMorePower(myUserId, userId));
 
-  const tag = getPowerLevelTag(userPower);
-  const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+  const tag = getMemberPowerTag(userId);
+  const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
 
   const [cords, setCords] = useState<RectCords>();
 
@@ -184,13 +194,13 @@ export function PowerChip({ userId }: { userId: string }) {
   const handlePowerSelect = (power: number): void => {
     close();
     if (!canChangePowers) return;
-    if (power === userPower) return;
+    if (power === getMemberPowerLevel(userId)) return;
 
     if (userId === mx.getSafeUserId()) {
       setSelfDemote(power);
       return;
     }
-    if (power === myPower) {
+    if (!creators.has(myUserId) && power === getMemberPowerLevel(myUserId)) {
       setSharedPower(power);
       return;
     }
@@ -242,19 +252,22 @@ export function PowerChip({ userId }: { userId: string }) {
                 {getPowers(powerLevelTags).map((power) => {
                   const powerTag = powerLevelTags[power];
                   const powerTagIconSrc =
-                    powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon);
+                    powerTag.icon && getPowerTagIconSrc(mx, useAuthentication, powerTag.icon);
 
-                  const canAssignPower = power <= myPower;
+                  const selected = getMemberPowerLevel(userId) === power;
+                  const canAssignPower = creators.has(myUserId)
+                    ? true
+                    : power <= getMemberPowerLevel(myUserId);
 
                   return (
                     <MenuItem
                       key={power}
-                      variant={userPower === power ? 'Primary' : 'Surface'}
+                      variant={selected ? 'Primary' : 'Surface'}
                       fill="None"
                       size="300"
                       radii="300"
                       aria-disabled={changing || !canChangePowers || !canAssignPower}
-                      aria-pressed={userPower === power}
+                      aria-pressed={selected}
                       before={<PowerColorBadge color={powerTag.color} />}
                       after={
                         powerTagIconSrc ? (
index ad23fef1fcaf98fb58bac6895c23f7c56f3cd515..9f8985afc87da734f170bd7142fb430bb24d2347 100644 (file)
@@ -5,12 +5,12 @@ 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 { usePowerLevels } 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 { createDM } from '../../../client/action/room';
 import { hasDevices } from '../../../util/matrixUtil';
 import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useAlive } from '../../hooks/useAlive';
@@ -20,6 +20,10 @@ import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './
 import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 import { useMembership } from '../../hooks/useMembership';
 import { Membership } from '../../../types/matrix/room';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
+import { CreatorChip } from './CreatorChip';
 
 type UserRoomProfileProps = {
   userId: string;
@@ -34,13 +38,19 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
   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 powerLevels = usePowerLevels(room);
+  const creators = useRoomCreators(room);
+
+  const permissions = useRoomPermissions(creators, powerLevels);
+  const { hasMorePower } = useMemberPowerCompare(creators, powerLevels);
+
+  const myUserId = mx.getSafeUserId();
+  const creator = creators.has(userId);
+
+  const canKickUser = permissions.action('kick', myUserId) && hasMorePower(myUserId, userId);
+  const canBanUser = permissions.action('ban', myUserId) && hasMorePower(myUserId, userId);
+  const canUnban = permissions.action('ban', myUserId);
+  const canInvite = permissions.action('invite', myUserId);
 
   const member = room.getMember(userId);
   const membership = useMembership(room, userId);
@@ -113,7 +123,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
           <Box alignItems="Center" gap="200" wrap="Wrap">
             {server && <ServerChip server={server} />}
             <ShareChip userId={userId} />
-            <PowerChip userId={userId} />
+            {creator ? <CreatorChip /> : <PowerChip userId={userId} />}
             <MutualRoomsChip userId={userId} />
             <OptionsChip userId={userId} />
           </Box>
@@ -123,7 +133,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
           <UserBanAlert
             userId={userId}
             reason={member.events.member?.getContent().reason}
-            canUnban={canBan}
+            canUnban={canUnban}
             bannedBy={member.events.member?.getSender()}
             ts={member.events.member?.getTs()}
           />
@@ -142,7 +152,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
           <UserInviteAlert
             userId={userId}
             reason={member.events.member?.getContent().reason}
-            canKick={canKick}
+            canKick={canKickUser}
             invitedBy={member.events.member?.getSender()}
             ts={member.events.member?.getTs()}
           />
@@ -150,8 +160,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
         <UserModeration
           userId={userId}
           canInvite={canInvite && membership === Membership.Leave}
-          canKick={canKick && membership === Membership.Join}
-          canBan={canBan && membership !== Membership.Ban}
+          canKick={canKickUser && membership === Membership.Join}
+          canBan={canBanUser && membership !== Membership.Ban}
         />
       </Box>
     </Box>
index 6ee19be9a16933bba72b8d6576d34c60455844ff..0ea96901c7db517eeacdb9427e285e914276f47e 100644 (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 '';
index 56dda548ca9d251c33c67d0e62fff3fffbe436da..fdbe546ee5839315177d66e7478ba68127c46389 100644 (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]);
index 9e1f1a97998b412cac37195d405e9941c27c5e20..400e73ae1fefb1fa92a69460fd2716aeaa4743f2 100644 (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);
index 1bb733999cd9515f9405c9bc23ea187ba9e5796c..15b1f15680b73414a224646f74395fd34103fda0 100644 (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;
   }>();
index 7b329b135759b3d6eac42f1eb871900ed43aca80..2e42785f353f064485878637aa8fca5e4dfcc04b 100644 (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 =
index f47ff757196bfe3bc5e228d75172099d65cc9b3a..b9e754991f21085fa5b26d8af3ef35c57a83763c 100644 (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>();
index a3a62e1c664c1838799290e06c573f9207d554c8..0f515c39ff596c44ebda0553222b26b855dd6fe6 100644 (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
index 9edfe89b8b6f82ee5ab138122d9342b048b14087..ce01421073ccaa9244c2563ba0357ff6c6775cbd 100644 (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>();
index 5d6bc5e2ef9e15331f0d96dccbe16614f3239875..45a480aa3171e725d9c6779a62005b3e85d25b3f 100644 (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>
   );
index dc802a1c1ea5c5668808cec57b4fd43d1469de6e..156f4f63b08803f93014d186925a2834117ff7ff 100644 (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,
index 54c9c5d2d42c892f44fa4529027c5c96deefa38c..121507733cb6472737ab6b22a5061386da80bec1 100644 (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}
index b7dadf5ab3ce8d354e500c5960151ec6c3419112..fe4f5689a5b1e416535f8927b95f77002aa431b6 100644 (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
index 25d2ba95c447090c9a2e259ea7d9ac4ed3f10895..9730594692a73092e67b8a3fb2e197d549c0f75e 100644 (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
index c88bf680511ae1284b57aada4e7877997fd8331a..6ad469c9becbe26c087722c80b8f8f51c7f95eae 100644 (file)
@@ -1,4 +1,4 @@
-import React, { FormEventHandler, useCallback, useState } from 'react';
+import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
 import { MatrixError, Room } from 'matrix-js-sdk';
 import {
   Box,
@@ -16,7 +16,12 @@ import {
 } from 'folds';
 import { SettingTile } from '../../components/setting-tile';
 import { SequenceCard } from '../../components/sequence-card';
-import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
+import {
+  creatorsSupported,
+  knockRestrictedSupported,
+  knockSupported,
+  restrictedSupported,
+} from '../../utils/matrix';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,12 +29,14 @@ import { useCapabilities } from '../../hooks/useCapabilities';
 import { useAlive } from '../../hooks/useAlive';
 import { ErrorCode } from '../../cs-errorcode';
 import {
+  AdditionalCreatorInput,
   createRoom,
   CreateRoomAliasInput,
   CreateRoomData,
   CreateRoomKind,
   CreateRoomKindSelector,
   RoomVersionSelector,
+  useAdditionalCreators,
 } from '../../components/create-room';
 
 const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
@@ -50,12 +57,19 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
   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 allowRestricted = space && restrictedSupported(selectedRoomVersion);
 
   const [kind, setKind] = useState(
     defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
   );
+  const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
+  const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
+    useAdditionalCreators();
   const [federation, setFederation] = useState(true);
   const [encryption, setEncryption] = useState(false);
   const [knock, setKnock] = useState(false);
@@ -112,6 +126,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
       encryption: publicRoom ? false : encryption,
       knock: roomKnock,
       allowFederation: federation,
+      additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
     }).then((roomId) => {
       if (alive()) {
         onCreate?.(roomId);
@@ -172,6 +187,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
             </Chip>
           </Box>
         </Box>
+        {allowAdditionalCreators && (
+          <SequenceCard
+            style={{ padding: config.space.S300 }}
+            variant="SurfaceVariant"
+            direction="Column"
+            gap="500"
+          >
+            <AdditionalCreatorInput
+              additionalCreators={additionalCreators}
+              onSelect={addAdditionalCreator}
+              onRemove={removeAdditionalCreator}
+            />
+          </SequenceCard>
+        )}
         {kind !== CreateRoomKind.Public && (
           <>
             <SequenceCard
index d964152a99c6f33226e93b41c18e1bcc109d8727..b1f9f649ed3ef7f93d6eaef4084a0f55a4aee6ac 100644 (file)
@@ -1,4 +1,4 @@
-import React, { FormEventHandler, useCallback, useState } from 'react';
+import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
 import { MatrixError, Room } from 'matrix-js-sdk';
 import {
   Box,
@@ -16,7 +16,12 @@ import {
 } from 'folds';
 import { SettingTile } from '../../components/setting-tile';
 import { SequenceCard } from '../../components/sequence-card';
-import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
+import {
+  creatorsSupported,
+  knockRestrictedSupported,
+  knockSupported,
+  restrictedSupported,
+} from '../../utils/matrix';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,12 +29,14 @@ import { useCapabilities } from '../../hooks/useCapabilities';
 import { useAlive } from '../../hooks/useAlive';
 import { ErrorCode } from '../../cs-errorcode';
 import {
+  AdditionalCreatorInput,
   createRoom,
   CreateRoomAliasInput,
   CreateRoomData,
   CreateRoomKind,
   CreateRoomKindSelector,
   RoomVersionSelector,
+  useAdditionalCreators,
 } from '../../components/create-room';
 import { RoomType } from '../../../types/matrix/room';
 
@@ -51,12 +58,20 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
   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 allowRestricted = space && restrictedSupported(selectedRoomVersion);
 
   const [kind, setKind] = useState(
     defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
   );
+
+  const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
+  const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
+    useAdditionalCreators();
   const [federation, setFederation] = useState(true);
   const [knock, setKnock] = useState(false);
   const [advance, setAdvance] = useState(false);
@@ -112,6 +127,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
       aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
       knock: roomKnock,
       allowFederation: federation,
+      additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
     }).then((roomId) => {
       if (alive()) {
         onCreate?.(roomId);
@@ -172,6 +188,20 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
             </Chip>
           </Box>
         </Box>
+        {allowAdditionalCreators && (
+          <SequenceCard
+            style={{ padding: config.space.S300 }}
+            variant="SurfaceVariant"
+            direction="Column"
+            gap="500"
+          >
+            <AdditionalCreatorInput
+              additionalCreators={additionalCreators}
+              onSelect={addAdditionalCreator}
+              onRemove={removeAdditionalCreator}
+            />
+          </SequenceCard>
+        )}
         {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
           <SequenceCard
             style={{ padding: config.space.S300 }}
index f126e7cb4563c9dc24881ad056eeaa4a64883270..ccbbe179d3c2cd307ede9682e296d0ccf33d7d2d 100644 (file)
@@ -27,6 +27,9 @@ import { stopPropagation } from '../../utils/keyboard';
 import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
 import { useSpaceOptionally } from '../../hooks/useSpace';
 import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
+import { IPowerLevels } from '../../hooks/usePowerLevels';
+import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
+import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
 
 type HierarchyItemWithParent = HierarchyItem & {
   parentId: string;
@@ -45,7 +48,7 @@ function SuggestMenuItem({
   const [toggleState, handleToggleSuggested] = useAsyncCallback(
     useCallback(() => {
       const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
-      return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
+      return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId);
     }, [mx, parentId, roomId, content])
   );
 
@@ -82,7 +85,7 @@ function RemoveMenuItem({
 
   const [removeState, handleRemove] = useAsyncCallback(
     useCallback(
-      () => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
+      () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId),
       [mx, parentId, roomId]
     )
   );
@@ -180,7 +183,7 @@ type HierarchyItemMenuProps = {
     parentId: string;
   };
   joined: boolean;
-  canInvite: boolean;
+  powerLevels?: IPowerLevels;
   canEditChild: boolean;
   pinned?: boolean;
   onTogglePin?: (roomId: string) => void;
@@ -188,13 +191,22 @@ type HierarchyItemMenuProps = {
 export function HierarchyItemMenu({
   item,
   joined,
-  canInvite,
+  powerLevels,
   canEditChild,
   pinned,
   onTogglePin,
 }: HierarchyItemMenuProps) {
+  const mx = useMatrixClient();
   const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 
+  const canInvite = (): boolean => {
+    if (!powerLevels) return false;
+    const creators = getRoomCreatorsForRoomId(mx, item.roomId);
+    const permissions = getRoomPermissionsAPI(creators, powerLevels);
+
+    return permissions.action('invite', mx.getSafeUserId());
+  };
+
   const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
     setMenuAnchor(evt.currentTarget.getBoundingClientRect());
   };
@@ -254,7 +266,7 @@ export function HierarchyItemMenu({
                     <InviteMenuItem
                       item={item}
                       requestClose={handleRequestClose}
-                      disabled={!canInvite}
+                      disabled={!canInvite()}
                     />
                     <SettingsMenuItem item={item} requestClose={handleRequestClose} />
                     <UseStateProvider initial={false}>
index 45610ff3e2eefb8e0df5fca74fde0507ea4cdf60..4b19e5163c6e1e60b8b21c0983a9f4c116d211f3 100644 (file)
@@ -27,7 +27,6 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
 import {
   IPowerLevels,
   PowerLevelsContextProvider,
-  powerLevelAPI,
   usePowerLevels,
   useRoomsPowerLevels,
 } from '../../hooks/usePowerLevels';
@@ -55,12 +54,13 @@ import { useRoomMembers } from '../../hooks/useRoomMembers';
 import { SpaceHierarchy } from './SpaceHierarchy';
 import { useGetRoom } from '../../hooks/useGetRoom';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
+import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
 
 const useCanDropLobbyItem = (
   space: Room,
   roomsPowerLevels: Map<string, IPowerLevels>,
-  getRoom: (roomId: string) => Room | undefined,
-  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
+  getRoom: (roomId: string) => Room | undefined
 ): CanDropCallback => {
   const mx = useMatrixClient();
 
@@ -74,16 +74,20 @@ const useCanDropLobbyItem = (
 
       const containerSpaceId = space.roomId;
 
+      const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {};
+      const creators = getRoomCreatorsForRoomId(mx, containerSpaceId);
+      const permissions = getRoomPermissionsAPI(creators, powerLevels);
+
       if (
         getRoom(containerSpaceId) === undefined ||
-        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+        !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
       ) {
         return false;
       }
 
       return true;
     },
-    [space, roomsPowerLevels, getRoom, canEditSpaceChild]
+    [space, roomsPowerLevels, getRoom, mx]
   );
 
   const canDropRoom: CanDropCallback = useCallback(
@@ -97,30 +101,31 @@ const useCanDropLobbyItem = (
       // check and do not allow restricted room to be dragged outside
       // current space if can't change `m.room.join_rules` `content.allow`
       if (draggingOutsideSpace && restrictedItem) {
-        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
-        const userPLInItem = powerLevelAPI.getPowerLevel(
-          itemPowerLevel,
-          mx.getUserId() ?? undefined
-        );
-        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
-          itemPowerLevel,
+        const itemPowerLevels = roomsPowerLevels.get(item.roomId) ?? {};
+        const itemCreators = getRoomCreatorsForRoomId(mx, item.roomId);
+        const itemPermissions = getRoomPermissionsAPI(itemCreators, itemPowerLevels);
+
+        const canChangeJoinRuleAllow = itemPermissions.stateEvent(
           StateEvent.RoomJoinRules,
-          userPLInItem
+          mx.getSafeUserId()
         );
         if (!canChangeJoinRuleAllow) {
           return false;
         }
       }
 
+      const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {};
+      const creators = getRoomCreatorsForRoomId(mx, containerSpaceId);
+      const permissions = getRoomPermissionsAPI(creators, powerLevels);
       if (
         getRoom(containerSpaceId) === undefined ||
-        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+        !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
       ) {
         return false;
       }
       return true;
     },
-    [mx, getRoom, canEditSpaceChild, roomsPowerLevels]
+    [mx, getRoom, roomsPowerLevels]
   );
 
   const canDrop: CanDropCallback = useCallback(
@@ -183,16 +188,6 @@ export function Lobby() {
 
   const getRoom = useGetRoom(allJoinedRooms);
 
-  const canEditSpaceChild = useCallback(
-    (powerLevels: IPowerLevels) =>
-      powerLevelAPI.canSendStateEvent(
-        powerLevels,
-        StateEvent.SpaceChild,
-        powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
-      ),
-    [mx]
-  );
-
   const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
   const hierarchy = useSpaceHierarchy(
     space.roomId,
@@ -229,12 +224,7 @@ export function Lobby() {
     )
   );
 
-  const canDrop: CanDropCallback = useCanDropLobbyItem(
-    space,
-    roomsPowerLevels,
-    getRoom,
-    canEditSpaceChild
-  );
+  const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom);
 
   const [reorderSpaceState, reorderSpace] = useAsyncCallback(
     useCallback(
@@ -270,7 +260,11 @@ export function Lobby() {
           .filter((reorder, index) => {
             if (!reorder.item.parentId) return false;
             const parentPL = roomsPowerLevels.get(reorder.item.parentId);
-            const canEdit = parentPL && canEditSpaceChild(parentPL);
+            if (!parentPL) return false;
+
+            const creators = getRoomCreatorsForRoomId(mx, reorder.item.parentId);
+            const permissions = getRoomPermissionsAPI(creators, parentPL);
+            const canEdit = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
             return canEdit && reorder.orderKey !== currentOrders[index];
           });
 
@@ -286,7 +280,7 @@ export function Lobby() {
           });
         }
       },
-      [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+      [mx, hierarchy, lex, roomsPowerLevels]
     )
   );
   const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
@@ -428,7 +422,7 @@ export function Lobby() {
         newItems.push(rId);
       }
       const newSpacesContent = makeCinnySpacesContent(mx, newItems);
-      mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+      mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
     },
     [mx, sidebarItems, sidebarSpaces]
   );
@@ -493,7 +487,6 @@ export function Lobby() {
                             allJoinedRooms={allJoinedRooms}
                             mDirects={mDirects}
                             roomsPowerLevels={roomsPowerLevels}
-                            canEditSpaceChild={canEditSpaceChild}
                             categoryId={categoryId}
                             closed={
                               closedCategories.has(categoryId) ||
index bc4c46fe76d90886c7e2ad2043182da4c4d227a1..77287123c1d0a40e9ef2c7ea7abfd5f185e78ea4 100644 (file)
@@ -27,7 +27,7 @@ import { RoomAvatar } from '../../components/room-avatar';
 import { nameInitials } from '../../utils/common';
 import * as css from './LobbyHeader.css';
 import { openInviteUser } from '../../../client/action/navigation';
-import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { IPowerLevels } from '../../hooks/usePowerLevels';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 import { stopPropagation } from '../../utils/keyboard';
@@ -36,26 +36,30 @@ import { BackRouteHandler } from '../../components/BackRouteHandler';
 import { mxcUrlToHttp } from '../../utils/matrix';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
 
 type LobbyMenuProps = {
-  roomId: string;
   powerLevels: IPowerLevels;
   requestClose: () => void;
 };
 const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
-  ({ roomId, powerLevels, requestClose }, ref) => {
+  ({ powerLevels, requestClose }, ref) => {
     const mx = useMatrixClient();
-    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
-    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+    const space = useSpace();
+    const creators = useRoomCreators(space);
+
+    const permissions = useRoomPermissions(creators, powerLevels);
+    const canInvite = permissions.action('invite', mx.getSafeUserId());
     const openSpaceSettings = useOpenSpaceSettings();
 
     const handleInvite = () => {
-      openInviteUser(roomId);
+      openInviteUser(space.roomId);
       requestClose();
     };
 
     const handleRoomSettings = () => {
-      openSpaceSettings(roomId);
+      openSpaceSettings(space.roomId);
       requestClose();
     };
 
@@ -106,7 +110,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
                 </MenuItem>
                 {promptLeave && (
                   <LeaveSpacePrompt
-                    roomId={roomId}
+                    roomId={space.roomId}
                     onDone={requestClose}
                     onCancel={() => setPromptLeave(false)}
                   />
@@ -242,7 +246,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
                 }}
               >
                 <LobbyMenu
-                  roomId={space.roomId}
                   powerLevels={powerLevels}
                   requestClose={() => setMenuAnchor(undefined)}
                 />
index a152bc19aebab0b85e8403f0f1aeb8b0e0959b3b..280b8a5a22aa91adb5db02cd1f95ce2ca67f92d0 100644 (file)
@@ -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<string>;
   mDirects: Set<string>;
   roomsPowerLevels: Map<string, IPowerLevels>;
-  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
   categoryId: string;
   closed: boolean;
   handleClose: MouseEventHandler<HTMLButtonElement>;
@@ -48,7 +49,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
       allJoinedRooms,
       mDirects,
       roomsPowerLevels,
-      canEditSpaceChild,
       categoryId,
       closed,
       handleClose,
@@ -79,25 +79,28 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
       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<HTMLDivElement, SpaceHierarchyProps>(
           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 && (
               <HierarchyItemMenu
                 item={{ ...spaceItem, parentId }}
-                canInvite={canInviteInSpace}
+                powerLevels={spacePowerLevels}
                 joined={allJoinedRooms.has(spaceItem.roomId)}
-                canEditChild={canEditSpaceChild(parentPowerLevels)}
+                canEditChild={
+                  !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
+                }
                 pinned={pinned}
                 onTogglePin={togglePinToSidebar}
               />
@@ -151,15 +158,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
               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<HTMLDivElement, SpaceHierarchyProps>(
                   dm={mDirects.has(roomItem.roomId)}
                   onOpen={onOpenRoom}
                   getRoom={getRoom}
-                  canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
+                  canReorder={
+                    !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
+                    !disabledReorder
+                  }
                   options={
                     <HierarchyItemMenu
                       item={roomItem}
-                      canInvite={canInviteInRoom}
+                      powerLevels={roomPowerLevels}
                       joined={allJoinedRooms.has(roomItem.roomId)}
-                      canEditChild={canEditSpaceChild(spacePowerLevels)}
+                      canEditChild={
+                        !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
+                      }
                     />
                   }
                   after={
index bc94092bec3494a0a7dbc882da397db68e53fe38..62ef9c4bd7c40f69168f6a7539b9987539fb11b6 100644 (file)
@@ -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}
                   />
index bdb8141850c84c9dd9c002cf4e93861709a82c63..ee8b678718df06687eea0ac124dff410397cc318 100644 (file)
@@ -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<HTMLDivElement, RoomNavItemMenuProps>(
     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();
 
index 0c3152c013f49ab549603a62f0054edd15deb477..d9c16c904fc7d074328ac968bdf52b4ad4af6699 100644 (file)
@@ -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 (
     <Page>
@@ -41,22 +45,22 @@ export function General({ requestClose }: GeneralProps) {
         <Scroll hideTrack visibility="Hover">
           <PageContent>
             <Box direction="Column" gap="700">
-              <RoomProfile powerLevels={powerLevels} />
+              <RoomProfile permissions={permissions} />
               <Box direction="Column" gap="100">
                 <Text size="L400">Options</Text>
-                <RoomJoinRules powerLevels={powerLevels} />
-                <RoomHistoryVisibility powerLevels={powerLevels} />
-                <RoomEncryption powerLevels={powerLevels} />
-                <RoomPublish powerLevels={powerLevels} />
+                <RoomJoinRules permissions={permissions} />
+                <RoomHistoryVisibility permissions={permissions} />
+                <RoomEncryption permissions={permissions} />
+                <RoomPublish permissions={permissions} />
               </Box>
               <Box direction="Column" gap="100">
                 <Text size="L400">Addresses</Text>
-                <RoomPublishedAddresses powerLevels={powerLevels} />
-                <RoomLocalAddresses powerLevels={powerLevels} />
+                <RoomPublishedAddresses permissions={permissions} />
+                <RoomLocalAddresses permissions={permissions} />
               </Box>
               <Box direction="Column" gap="100">
                 <Text size="L400">Advance Options</Text>
-                <RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
+                <RoomUpgrade permissions={permissions} requestClose={requestClose} />
               </Box>
             </Box>
           </PageContent>
index ae3769bf189d4971d8aa9a3d8356d1fb68394ee9..7572a71bf4c7d508b94bc3b5bddaf6beb97d7abc 100644 (file)
@@ -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}
               />
-              <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
+              <PermissionGroups
+                canEdit={canEditPermissions}
+                powerLevels={powerLevels}
+                permissionGroups={permissionGroups}
+              />
             </Box>
           </PageContent>
         </Scroll>
index a1f4153e84595a1f54f519200ec4e9dfb69dd41d..860ceda01f71d632fd00f470d438b492ceddf3e1 100644 (file)
@@ -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({
index bdb0eb3df629abbd24cb6e87d0db67b72c7d5e2f..46d2238a9a67d8efb1ba63664a90b0189c8ddfdc 100644 (file)
@@ -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 (
+    <Header className={css.MembersDrawerHeader} variant="Background" size="600">
+      <Box grow="Yes" alignItems="Center" gap="200">
+        <Box grow="Yes" alignItems="Center" gap="200">
+          <Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
+            {`${millify(room.getJoinedMemberCount())} Members`}
+          </Text>
+        </Box>
+        <Box shrink="No" alignItems="Center">
+          <TooltipProvider
+            position="Bottom"
+            align="End"
+            offset={4}
+            tooltip={
+              <Tooltip>
+                <Text>Close</Text>
+              </Tooltip>
+            }
+          >
+            {(triggerRef) => (
+              <IconButton
+                ref={triggerRef}
+                variant="Background"
+                onClick={() => setPeopleDrawer(false)}
+              >
+                <Icon src={Icons.Cross} />
+              </IconButton>
+            )}
+          </TooltipProvider>
+        </Box>
+      </Box>
+    </Header>
+  );
+}
+
+type MemberItemProps = {
+  mx: MatrixClient;
+  useAuthentication: boolean;
+  room: Room;
+  member: RoomMember;
+  onClick: MouseEventHandler<HTMLButtonElement>;
+  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 (
+    <MenuItem
+      style={{ padding: `0 ${config.space.S200}` }}
+      aria-pressed={pressed}
+      data-user-id={member.userId}
+      variant="Background"
+      radii="400"
+      onClick={onClick}
+      before={
+        <Avatar size="200">
+          <UserAvatar
+            userId={member.userId}
+            src={avatarUrl ?? undefined}
+            alt={name}
+            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+          />
+        </Avatar>
+      }
+      after={
+        typing && (
+          <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+            <TypingIndicator size="300" />
+          </Badge>
+        )
+      }
+    >
+      <Box grow="Yes">
+        <Text size="T400" truncate>
+          {name}
+        </Text>
+      </Box>
+    </MenuItem>
+  );
+}
 
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
   limit: 1000,
@@ -80,9 +183,10 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
   const searchInputRef = useRef<HTMLInputElement>(null);
   const scrollTopAnchorRef = useRef<HTMLDivElement>(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<HTMLButtonElement> = (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 (
-    <Box className={css.MembersDrawer} shrink="No" direction="Column">
-      <Header className={css.MembersDrawerHeader} variant="Background" size="600">
-        <Box grow="Yes" alignItems="Center" gap="200">
-          <Box grow="Yes" alignItems="Center" gap="200">
-            <Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
-              {`${millify(room.getJoinedMemberCount())} Members`}
-            </Text>
-          </Box>
-          <Box shrink="No" alignItems="Center">
-            <TooltipProvider
-              position="Bottom"
-              align="End"
-              offset={4}
-              tooltip={
-                <Tooltip>
-                  <Text>Close</Text>
-                </Tooltip>
-              }
-            >
-              {(triggerRef) => (
-                <IconButton
-                  ref={triggerRef}
-                  variant="Background"
-                  onClick={() => setPeopleDrawer(false)}
-                >
-                  <Icon src={Icons.Cross} />
-                </IconButton>
-              )}
-            </TooltipProvider>
-          </Box>
-        </Box>
-      </Header>
+    <Box
+      className={classNames(css.MembersDrawer, ContainerColor({ variant: 'Background' }))}
+      shrink="No"
+      direction="Column"
+    >
+      <MemberDrawerHeader room={room} />
       <Box className={css.MemberDrawerContentBase} grow="Yes">
         <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
           <Box className={css.MemberDrawerContent} direction="Column" gap="200">
@@ -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 (
-                    <MenuItem
+                    <div
                       style={{
-                        padding: `0 ${config.space.S200}`,
                         transform: `translateY(${vItem.start}px)`,
                       }}
-                      aria-pressed={openProfileUserId === member.userId}
+                      className={css.DrawerVirtualItem}
                       data-index={vItem.index}
-                      data-user-id={member.userId}
+                      key={`${room.roomId}-${tagOrMember.userId}`}
                       ref={virtualizer.measureElement}
-                      key={`${room.roomId}-${member.userId}`}
-                      className={css.DrawerVirtualItem}
-                      variant="Background"
-                      radii="400"
-                      onClick={handleMemberClick}
-                      before={
-                        <Avatar size="200">
-                          <UserAvatar
-                            userId={member.userId}
-                            src={avatarUrl ?? undefined}
-                            alt={name}
-                            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
-                          />
-                        </Avatar>
-                      }
-                      after={
-                        typingMembers.find((receipt) => receipt.userId === member.userId) && (
-                          <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
-                            <TypingIndicator size="300" />
-                          </Badge>
-                        )
-                      }
                     >
-                      <Box grow="Yes">
-                        <Text size="T400" truncate>
-                          {name}
-                        </Text>
-                      </Box>
-                    </MenuItem>
+                      <MemberItem
+                        mx={mx}
+                        useAuthentication={useAuthentication}
+                        room={room}
+                        member={tagOrMember}
+                        onClick={handleMemberClick}
+                        pressed={openProfileUserId === tagOrMember.userId}
+                        typing={typingMembers.some(
+                          (receipt) => receipt.userId === tagOrMember.userId
+                        )}
+                      />
+                    </div>
                   );
                 })}
               </div>
index 1399ec15dc2780e73bcb71ca6f073cf0b5cec105..76bafc9e02f6b0b2592dde1b4c4eb0cfa121d2bc 100644 (file)
@@ -108,21 +108,23 @@ import { ReplyLayout, ThreadIndicator } from '../../components/message';
 import { roomToParentsAtom } from '../../state/room/roomToParents';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useImagePackRooms } from '../../hooks/useImagePackRooms';
-import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
-import { powerLevelAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import colorMXID from '../../../util/colorMXID';
 import { useIsDirectRoom } from '../../hooks/useRoom';
+import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useTheme } from '../../hooks/useTheme';
+import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
+import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
 
 interface RoomInputProps {
   editor: Editor;
   fileDropContainerRef: RefObject<HTMLElement>;
   roomId: string;
   room: Room;
-  getPowerLevelTag: GetPowerLevelTag;
-  accessibleTagColors: Map<string, string>;
 }
 export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
-  ({ editor, fileDropContainerRef, roomId, room, getPowerLevelTag, accessibleTagColors }, ref) => {
+  ({ editor, fileDropContainerRef, roomId, room }, ref) => {
     const mx = useMatrixClient();
     const useAuthentication = useMediaAuthentication();
     const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -134,13 +136,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     const emojiBtnRef = useRef<HTMLButtonElement>(null);
     const roomToParents = useAtomValue(roomToParentsAtom);
     const powerLevels = usePowerLevelsContext();
+    const creators = useRoomCreators(room);
 
     const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
     const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
     const replyUserID = replyDraft?.userId;
 
-    const replyPowerTag = getPowerLevelTag(powerLevelAPI.getPowerLevel(powerLevels, replyUserID));
-    const replyPowerColor = replyPowerTag.color
+    const powerLevelTags = usePowerLevelTags(room, powerLevels);
+    const creatorsTag = useRoomCreatorsTag();
+    const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+    const theme = useTheme();
+    const accessibleTagColors = useAccessiblePowerTagColors(
+      theme.kind,
+      creatorsTag,
+      powerLevelTags
+    );
+
+    const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
+    const replyPowerColor = replyPowerTag?.color
       ? accessibleTagColors.get(replyPowerTag.color)
       : undefined;
     const replyUsernameColor =
@@ -277,7 +290,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       });
       handleCancelUpload(uploads);
       const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
-      contents.forEach((content) => mx.sendMessage(roomId, content));
+      contents.forEach((content) => mx.sendMessage(roomId, content as any));
     };
 
     const submit = useCallback(() => {
@@ -356,7 +369,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           content['m.relates_to'].is_falling_back = false;
         }
       }
-      mx.sendMessage(roomId, content);
+      mx.sendMessage(roomId, content as any);
       resetEditor(editor);
       resetEditorHistory(editor);
       setReplyDraft(undefined);
index 90f09012be82055d2ab93f962891dd31693fcfcc..7a012e4222fb0ae3b74bfa521cd9301f3661067f 100644 (file)
@@ -101,7 +101,7 @@ import * as css from './RoomTimeline.css';
 import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
 import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
 import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
-import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
 import { useKeyDown } from '../../hooks/useKeyDown';
 import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
@@ -117,10 +117,15 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 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';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
+import { useTheme } from '../../hooks/useTheme';
+import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
+import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -223,8 +228,6 @@ type RoomTimelineProps = {
   eventId?: string;
   roomInputRef: RefObject<HTMLElement>;
   editor: Editor;
-  getPowerLevelTag: GetPowerLevelTag;
-  accessibleTagColors: Map<string, string>;
 };
 
 const PAGINATION_LIMIT = 80;
@@ -427,14 +430,7 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
   };
 };
 
-export function RoomTimeline({
-  room,
-  eventId,
-  roomInputRef,
-  editor,
-  getPowerLevelTag,
-  accessibleTagColors,
-}: RoomTimelineProps) {
+export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
   const mx = useMatrixClient();
   const useAuthentication = useMediaAuthentication();
   const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -459,13 +455,24 @@ export function RoomTimeline({
 
   const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
   const powerLevels = usePowerLevelsContext();
-  const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
-    usePowerLevelsAPI(powerLevels);
+  const creators = useRoomCreators(room);
 
-  const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
-  const canRedact = canDoAction('redact', myPowerLevel);
-  const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
-  const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
+  const creatorsTag = useRoomCreatorsTag();
+  const powerLevelTags = usePowerLevelTags(room, powerLevels);
+  const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+
+  const theme = useTheme();
+  const accessiblePowerTagColors = useAccessiblePowerTagColors(
+    theme.kind,
+    creatorsTag,
+    powerLevelTags
+  );
+
+  const permissions = useRoomPermissions(creators, powerLevels);
+
+  const canRedact = permissions.action('redact', mx.getSafeUserId());
+  const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
+  const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
   const [editId, setEditId] = useState<string>();
 
   const roomToParents = useAtomValue(roomToParentsAtom);
@@ -990,7 +997,7 @@ export function RoomTimeline({
         (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
       mx.sendEvent(
         room.roomId,
-        MessageEvent.Reaction,
+        MessageEvent.Reaction as any,
         getReactionContent(targetEventId, key, rShortcode)
       );
     },
@@ -1025,7 +1032,6 @@ export function RoomTimeline({
           editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
 
         const senderId = mEvent.getSender() ?? '';
-        const senderPowerLevel = getPowerLevel(mEvent.getSender());
         const senderDisplayName =
           getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
 
@@ -1059,9 +1065,8 @@ export function RoomTimeline({
                   replyEventId={replyEventId}
                   threadRootId={threadRootId}
                   onClick={handleOpenReply}
-                  getPowerLevel={getPowerLevel}
-                  getPowerLevelTag={getPowerLevelTag}
-                  accessibleTagColors={accessibleTagColors}
+                  getMemberPowerTag={getMemberPowerTag}
+                  accessibleTagColors={accessiblePowerTagColors}
                   legacyUsernameColor={legacyUsernameColor || direct}
                 />
               )
@@ -1080,8 +1085,8 @@ export function RoomTimeline({
             }
             hideReadReceipts={hideActivity}
             showDeveloperTools={showDeveloperTools}
-            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
-            accessibleTagColors={accessibleTagColors}
+            memberPowerTag={getMemberPowerTag(senderId)}
+            accessibleTagColors={accessiblePowerTagColors}
             legacyUsernameColor={legacyUsernameColor || direct}
             hour24Clock={hour24Clock}
             dateFormatString={dateFormatString}
@@ -1111,7 +1116,6 @@ export function RoomTimeline({
         const hasReactions = reactions && reactions.length > 0;
         const { replyEventId, threadRootId } = mEvent;
         const highlighted = focusItem?.index === item && focusItem.highlight;
-        const senderPowerLevel = getPowerLevel(mEvent.getSender());
 
         return (
           <Message
@@ -1143,9 +1147,8 @@ export function RoomTimeline({
                   replyEventId={replyEventId}
                   threadRootId={threadRootId}
                   onClick={handleOpenReply}
-                  getPowerLevel={getPowerLevel}
-                  getPowerLevelTag={getPowerLevelTag}
-                  accessibleTagColors={accessibleTagColors}
+                  getMemberPowerTag={getMemberPowerTag}
+                  accessibleTagColors={accessiblePowerTagColors}
                   legacyUsernameColor={legacyUsernameColor || direct}
                 />
               )
@@ -1164,8 +1167,8 @@ export function RoomTimeline({
             }
             hideReadReceipts={hideActivity}
             showDeveloperTools={showDeveloperTools}
-            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
-            accessibleTagColors={accessibleTagColors}
+            memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
+            accessibleTagColors={accessiblePowerTagColors}
             legacyUsernameColor={legacyUsernameColor || direct}
             hour24Clock={hour24Clock}
             dateFormatString={dateFormatString}
@@ -1232,7 +1235,6 @@ export function RoomTimeline({
         const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
         const hasReactions = reactions && reactions.length > 0;
         const highlighted = focusItem?.index === item && focusItem.highlight;
-        const senderPowerLevel = getPowerLevel(mEvent.getSender());
 
         return (
           <Message
@@ -1268,8 +1270,8 @@ export function RoomTimeline({
             }
             hideReadReceipts={hideActivity}
             showDeveloperTools={showDeveloperTools}
-            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
-            accessibleTagColors={accessibleTagColors}
+            memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
+            accessibleTagColors={accessiblePowerTagColors}
             legacyUsernameColor={legacyUsernameColor || direct}
             hour24Clock={hour24Clock}
             dateFormatString={dateFormatString}
index b6eebdf2308a8db65134e607eaadf4b328568ff2..c4a09100d56e329782cf9cd6ea6d01c7098a55b3 100644 (file)
@@ -5,7 +5,7 @@ import { ReactEditor } from 'slate-react';
 import { isKeyHotkey } from 'is-hotkey';
 import { useStateEvent } from '../../hooks/useStateEvent';
 import { StateEvent } from '../../../types/matrix/room';
-import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { useEditor } from '../../components/editor';
 import { RoomInputPlaceholder } from './RoomInputPlaceholder';
@@ -21,8 +21,8 @@ import { editableActiveElement } from '../../utils/dom';
 import navigation from '../../../client/state/navigation';
 import { settingsAtom } from '../../state/settings';
 import { useSetting } from '../../state/hooks/settings';
-import { useAccessibleTagColors, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
-import { useTheme } from '../../hooks/useTheme';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
 
 const FN_KEYS_REGEX = /^F\d+$/;
 const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@@ -70,15 +70,10 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
 
   const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
   const powerLevels = usePowerLevelsContext();
-  const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
-  const myUserId = mx.getUserId();
-  const canMessage = myUserId
-    ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
-    : false;
+  const creators = useRoomCreators(room);
 
-  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
-  const theme = useTheme();
-  const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
+  const permissions = useRoomPermissions(creators, powerLevels);
+  const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
 
   useKeyDown(
     window,
@@ -109,8 +104,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
           eventId={eventId}
           roomInputRef={roomInputRef}
           editor={editor}
-          getPowerLevelTag={getPowerLevelTag}
-          accessibleTagColors={accessibleTagColors}
         />
         <RoomViewTyping room={room} />
       </Box>
@@ -131,8 +124,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
                   roomId={roomId}
                   fileDropContainerRef={roomViewRef}
                   ref={roomInputRef}
-                  getPowerLevelTag={getPowerLevelTag}
-                  accessibleTagColors={accessibleTagColors}
                 />
               )}
               {!canMessage && (
index d8e2e8b92b3417f5b1ac8b5939c69d189a444d3e..291c21c0a6cbd93ec5023d8bbebfa7ff6cc80d9f 100644 (file)
@@ -42,7 +42,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
 import { _SearchPathSearchParams } from '../../pages/paths';
 import * as css from './RoomViewHeader.css';
 import { useRoomUnread } from '../../state/hooks/unread';
-import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { markAsRead } from '../../../client/action/notifications';
 import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 import { openInviteUser } from '../../../client/action/navigation';
@@ -67,6 +67,8 @@ import {
 } from '../../hooks/useRoomsNotificationPreferences';
 import { JumpToTime } from './jump-to-time';
 import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
 
 type RoomMenuProps = {
   room: Room;
@@ -77,8 +79,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
   const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
   const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
   const powerLevels = usePowerLevelsContext();
-  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 notificationPreferences = useRoomsNotificationPreferencesContext();
   const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
   const { navigateRoom } = useRoomNavigate();
index e906a0244e34d938b76f73a356292a61282249f8..fbe35770c4b5252cad4a3b17249a4c31ec3a5bf3 100644 (file)
@@ -75,10 +75,10 @@ import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
 import { getViaServers } from '../../../plugins/via-servers';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
-import { StateEvent } from '../../../../types/matrix/room';
-import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags';
+import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
 import { PowerIcon } from '../../../components/power';
 import colorMXID from '../../../../util/colorMXID';
+import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
 
 export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
 
@@ -371,7 +371,7 @@ export const MessagePinItem = as<
     if (!isPinned && eventId) {
       pinContent.pinned.push(eventId);
     }
-    mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent);
+    mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent);
     onClose?.();
   };
 
@@ -679,7 +679,7 @@ export type MessageProps = {
   reactions?: ReactNode;
   hideReadReceipts?: boolean;
   showDeveloperTools?: boolean;
-  powerLevelTag?: PowerLevelTag;
+  memberPowerTag?: MemberPowerTag;
   accessibleTagColors?: Map<string, string>;
   legacyUsernameColor?: boolean;
   hour24Clock: boolean;
@@ -710,7 +710,7 @@ export const Message = as<'div', MessageProps>(
       reactions,
       hideReadReceipts,
       showDeveloperTools,
-      powerLevelTag,
+      memberPowerTag,
       accessibleTagColors,
       legacyUsernameColor,
       hour24Clock,
@@ -733,11 +733,11 @@ export const Message = as<'div', MessageProps>(
       getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
     const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
 
-    const tagColor = powerLevelTag?.color
-      ? accessibleTagColors?.get(powerLevelTag.color)
+    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(senderId) : tagColor;
index 8e73e66ee72612e9b2427ec6299d105c8a1586d1..9986849f4af51ef78d20d2cbf9e88c6c369873ae 100644 (file)
@@ -69,18 +69,23 @@ import { Image } from '../../../components/media';
 import { ImageViewer } from '../../../components/image-viewer';
 import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
 import { VirtualTile } from '../../../components/virtualizer';
-import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels';
+import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
 import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 import { ContainerColor } from '../../../styles/ContainerColor.css';
-import {
-  getTagIconSrc,
-  useAccessibleTagColors,
-  usePowerLevelTags,
-} from '../../../hooks/usePowerLevelTags';
+import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
 import { useTheme } from '../../../hooks/useTheme';
 import { PowerIcon } from '../../../components/power';
 import colorMXID from '../../../../util/colorMXID';
 import { useIsDirectRoom } from '../../../hooks/useRoom';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import {
+  GetMemberPowerTag,
+  getPowerTagIconSrc,
+  useAccessiblePowerTagColors,
+  useGetMemberPowerTag,
+} from '../../../hooks/useMemberPowerTag';
+import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
 
 type PinnedMessageProps = {
   room: Room;
@@ -88,22 +93,27 @@ type PinnedMessageProps = {
   renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
   onOpen: (roomId: string, eventId: string) => void;
   canPinEvent: boolean;
+  getMemberPowerTag: GetMemberPowerTag;
+  accessibleTagColors: Map<string, string>;
+  legacyUsernameColor: boolean;
+  hour24Clock: boolean;
+  dateFormatString: string;
 };
-function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: PinnedMessageProps) {
+function PinnedMessage({
+  room,
+  eventId,
+  renderContent,
+  onOpen,
+  canPinEvent,
+  getMemberPowerTag,
+  accessibleTagColors,
+  legacyUsernameColor,
+  hour24Clock,
+  dateFormatString,
+}: PinnedMessageProps) {
   const pinnedEvent = useRoomEvent(room, eventId);
   const useAuthentication = useMediaAuthentication();
   const mx = useMatrixClient();
-  const direct = useIsDirectRoom();
-  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
-
-  const powerLevels = usePowerLevelsContext();
-  const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
-  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
-  const theme = useTheme();
-  const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
-
-  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
-  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 
   const [unpinState, unpin] = useAsyncCallback(
     useCallback(() => {
@@ -169,14 +179,15 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
   const senderAvatarMxc = getMemberAvatarMxc(room, sender);
   const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
 
-  const senderPowerLevel = getPowerLevel(sender);
-  const powerLevelTag = getPowerLevelTag(senderPowerLevel);
-  const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined;
-  const tagIconSrc = powerLevelTag?.icon
-    ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
+  const memberPowerTag = getMemberPowerTag(sender);
+  const tagColor = memberPowerTag?.color
+    ? accessibleTagColors?.get(memberPowerTag.color)
+    : undefined;
+  const tagIconSrc = memberPowerTag?.icon
+    ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
     : undefined;
 
-  const usernameColor = legacyUsernameColor || direct ? colorMXID(sender) : tagColor;
+  const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor;
 
   return (
     <ModernLayout
@@ -222,8 +233,7 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
           replyEventId={pinnedEvent.replyEventId}
           threadRootId={pinnedEvent.threadRootId}
           onClick={handleOpenClick}
-          getPowerLevel={getPowerLevel}
-          getPowerLevelTag={getPowerLevelTag}
+          getMemberPowerTag={getMemberPowerTag}
           accessibleTagColors={accessibleTagColors}
           legacyUsernameColor={legacyUsernameColor}
         />
@@ -242,14 +252,34 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
     const mx = useMatrixClient();
     const userId = mx.getUserId()!;
     const powerLevels = usePowerLevelsContext();
-    const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
-    const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, getPowerLevel(userId));
+    const creators = useRoomCreators(room);
+
+    const permissions = useRoomPermissions(creators, powerLevels);
+    const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, userId);
+
+    const creatorsTag = useRoomCreatorsTag();
+    const powerLevelTags = usePowerLevelTags(room, powerLevels);
+    const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+
+    const theme = useTheme();
+    const accessibleTagColors = useAccessiblePowerTagColors(
+      theme.kind,
+      creatorsTag,
+      powerLevelTags
+    );
 
     const pinnedEvents = useRoomPinnedEvents(room);
     const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]);
     const useAuthentication = useMediaAuthentication();
     const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
     const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+
+    const direct = useIsDirectRoom();
+    const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+
+    const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+    const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
     const { navigateRoom } = useRoomNavigate();
     const scrollRef = useRef<HTMLDivElement>(null);
 
@@ -464,6 +494,11 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
                               renderContent={renderMatrixEvent}
                               onOpen={handleOpen}
                               canPinEvent={canPinEvent}
+                              getMemberPowerTag={getMemberPowerTag}
+                              accessibleTagColors={accessibleTagColors}
+                              legacyUsernameColor={legacyUsernameColor || direct}
+                              hour24Clock={hour24Clock}
+                              dateFormatString={dateFormatString}
                             />
                           </SequenceCard>
                         </VirtualTile>
index 6f4d8d38a4cc92c540fadf7405fb770b71df1eb0..641bfa7a7b2278f213ff92459441ea7991933818 100644 (file)
@@ -11,6 +11,8 @@ import {
   RoomPublish,
   RoomUpgrade,
 } from '../../common-settings/general';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
 
 type GeneralProps = {
   requestClose: () => void;
@@ -18,6 +20,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 (
     <Page>
@@ -39,20 +43,20 @@ export function General({ requestClose }: GeneralProps) {
         <Scroll hideTrack visibility="Hover">
           <PageContent>
             <Box direction="Column" gap="700">
-              <RoomProfile powerLevels={powerLevels} />
+              <RoomProfile permissions={permissions} />
               <Box direction="Column" gap="100">
                 <Text size="L400">Options</Text>
-                <RoomJoinRules powerLevels={powerLevels} />
-                <RoomPublish powerLevels={powerLevels} />
+                <RoomJoinRules permissions={permissions} />
+                <RoomPublish permissions={permissions} />
               </Box>
               <Box direction="Column" gap="100">
                 <Text size="L400">Addresses</Text>
-                <RoomPublishedAddresses powerLevels={powerLevels} />
-                <RoomLocalAddresses powerLevels={powerLevels} />
+                <RoomPublishedAddresses permissions={permissions} />
+                <RoomLocalAddresses permissions={permissions} />
               </Box>
               <Box direction="Column" gap="100">
                 <Text size="L400">Advance Options</Text>
-                <RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
+                <RoomUpgrade permissions={permissions} requestClose={requestClose} />
               </Box>
             </Box>
           </PageContent>
index ae3769bf189d4971d8aa9a3d8356d1fb68394ee9..7572a71bf4c7d508b94bc3b5bddaf6beb97d7abc 100644 (file)
@@ -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}
               />
-              <PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
+              <PermissionGroups
+                canEdit={canEditPermissions}
+                powerLevels={powerLevels}
+                permissionGroups={permissionGroups}
+              />
             </Box>
           </PageContent>
         </Scroll>
diff --git a/src/app/hooks/useDirectUsers.ts b/src/app/hooks/useDirectUsers.ts
new file mode 100644 (file)
index 0000000..3aa1892
--- /dev/null
@@ -0,0 +1,27 @@
+import { useMemo } from 'react';
+import { AccountDataEvent, MDirectContent } from '../../types/matrix/accountData';
+import { useAccountData } from './useAccountData';
+import { useAllJoinedRoomsSet, useGetRoom } from './useGetRoom';
+
+export const useDirectUsers = (): string[] => {
+  const directEvent = useAccountData(AccountDataEvent.Direct);
+  const content = directEvent?.getContent<MDirectContent>();
+
+  const allJoinedRooms = useAllJoinedRoomsSet();
+  const getRoom = useGetRoom(allJoinedRooms);
+
+  const users = useMemo(() => {
+    if (typeof content !== 'object') return [];
+
+    const u = Object.keys(content).filter((userId) => {
+      const rooms = content[userId];
+      if (!Array.isArray(rooms)) return false;
+      const hasDM = rooms.some((roomId) => typeof roomId === 'string' && !!getRoom(roomId));
+      return hasDM;
+    });
+
+    return u;
+  }, [content, getRoom]);
+
+  return users;
+};
diff --git a/src/app/hooks/useMemberPowerCompare.ts b/src/app/hooks/useMemberPowerCompare.ts
new file mode 100644 (file)
index 0000000..72163ed
--- /dev/null
@@ -0,0 +1,28 @@
+import { useCallback } from 'react';
+import { IPowerLevels, readPowerLevel } from './usePowerLevels';
+
+export const useMemberPowerCompare = (creators: Set<string>, powerLevels: IPowerLevels) => {
+  /**
+   * returns `true` if `userIdA` has more power than `userIdB`
+   * returns `false` otherwise
+   */
+  const hasMorePower = useCallback(
+    (userIdA: string, userIdB: string): boolean => {
+      const aIsCreator = creators.has(userIdA);
+      const bIsCreator = creators.has(userIdB);
+      if (aIsCreator && bIsCreator) return false;
+      if (aIsCreator) return true;
+      if (bIsCreator) return false;
+
+      const aPower = readPowerLevel.user(powerLevels, userIdA);
+      const bPower = readPowerLevel.user(powerLevels, userIdB);
+
+      return aPower > bPower;
+    },
+    [creators, powerLevels]
+  );
+
+  return {
+    hasMorePower,
+  };
+};
diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts
new file mode 100644 (file)
index 0000000..31e52aa
--- /dev/null
@@ -0,0 +1,87 @@
+import { useCallback, useMemo } from 'react';
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
+import { getPowerLevelTag, PowerLevelTags, usePowerLevelTags } from './usePowerLevelTags';
+import { IPowerLevels, readPowerLevel } from './usePowerLevels';
+import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room';
+import { useRoomCreatorsTag } from './useRoomCreatorsTag';
+import { ThemeKind } from './useTheme';
+import { accessibleColor } from '../plugins/color';
+
+export type GetMemberPowerTag = (userId: string) => MemberPowerTag;
+
+export const useGetMemberPowerTag = (
+  room: Room,
+  creators: Set<string>,
+  powerLevels: IPowerLevels
+) => {
+  const creatorsTag = useRoomCreatorsTag();
+  const powerLevelTags = usePowerLevelTags(room, powerLevels);
+
+  const getMemberPowerTag: GetMemberPowerTag = useCallback(
+    (userId) => {
+      if (creators.has(userId)) {
+        return creatorsTag;
+      }
+
+      const power = readPowerLevel.user(powerLevels, userId);
+      return getPowerLevelTag(powerLevelTags, power);
+    },
+    [creators, creatorsTag, powerLevels, powerLevelTags]
+  );
+
+  return getMemberPowerTag;
+};
+
+export const getPowerTagIconSrc = (
+  mx: MatrixClient,
+  useAuthentication: boolean,
+  icon: MemberPowerTagIcon
+): string | undefined =>
+  icon?.key?.startsWith('mxc://')
+    ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
+    : icon?.key;
+
+export const useAccessiblePowerTagColors = (
+  themeKind: ThemeKind,
+  creatorsTag: MemberPowerTag,
+  powerLevelTags: PowerLevelTags
+): Map<string, string> => {
+  const accessibleColors: Map<string, string> = useMemo(() => {
+    const colors: Map<string, string> = new Map();
+    if (creatorsTag.color) {
+      colors.set(creatorsTag.color, accessibleColor(themeKind, creatorsTag.color));
+    }
+
+    Object.values(powerLevelTags).forEach((tag) => {
+      const { color } = tag;
+      if (!color) return;
+
+      colors.set(color, accessibleColor(themeKind, color));
+    });
+
+    return colors;
+  }, [powerLevelTags, creatorsTag, themeKind]);
+
+  return accessibleColors;
+};
+
+export const useFlattenPowerTagMembers = (
+  members: RoomMember[],
+  getTag: GetMemberPowerTag
+): Array<MemberPowerTag | RoomMember> => {
+  const PLTagOrRoomMember = useMemo(() => {
+    let prevTag: MemberPowerTag | undefined;
+    const tagOrMember: Array<MemberPowerTag | RoomMember> = [];
+    members.forEach((member) => {
+      const tag = getTag(member.userId);
+      if (tag !== prevTag) {
+        prevTag = tag;
+        tagOrMember.push(tag);
+      }
+      tagOrMember.push(member);
+    });
+    return tagOrMember;
+  }, [members, getTag]);
+
+  return PLTagOrRoomMember;
+};
index da95570161a3cf92d9c0356744de8fa9fad9daeb..d8e403c52254652feadb1d1e3aec738d0a644ffe 100644 (file)
@@ -1,5 +1,5 @@
 import { RoomMember } from 'matrix-js-sdk';
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
 
 export const MemberSort = {
   Ascending: (a: RoomMember, b: RoomMember) =>
@@ -46,3 +46,20 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb
   const item = memberSort[index] ?? memberSort[0];
   return item;
 };
+
+export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
+  const sort: MemberSortFn = useCallback(
+    (a, b) => {
+      if (creators.has(a.userId) && creators.has(b.userId)) {
+        return 0;
+      }
+      if (creators.has(a.userId)) return -1;
+      if (creators.has(b.userId)) return 1;
+
+      return b.powerLevel - a.powerLevel;
+    },
+    [creators]
+  );
+
+  return sort;
+};
index bdcb9bcc272b4f8a470b02f1cfb29ed261498f10..10235b5269455cbfeaba0a7acfab35d46f6d337e 100644 (file)
@@ -1,29 +1,24 @@
-import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
-import { useCallback, useMemo } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useMemo } from 'react';
 import { IPowerLevels } from './usePowerLevels';
 import { useStateEvent } from './useStateEvent';
-import { StateEvent } from '../../types/matrix/room';
-import { IImageInfo } from '../../types/matrix/common';
-import { ThemeKind } from './useTheme';
-import { accessibleColor } from '../plugins/color';
-
-export type PowerLevelTagIcon = {
-  key?: string;
-  info?: IImageInfo;
-};
-export type PowerLevelTag = {
-  name: string;
-  color?: string;
-  icon?: PowerLevelTagIcon;
-};
+import { MemberPowerTag, StateEvent } from '../../types/matrix/room';
 
-export type PowerLevelTags = Record<number, PowerLevelTag>;
+export type PowerLevelTags = Record<number, MemberPowerTag>;
 
-export const powerSortFn = (a: number, b: number) => b - a;
-export const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
+const powerSortFn = (a: number, b: number) => b - a;
+const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
 
 export const getPowers = (tags: PowerLevelTags): number[] => {
-  const powers: number[] = Object.keys(tags).map((p) => parseInt(p, 10));
+  const powers: number[] = Object.keys(tags)
+    .map((p) => {
+      const power = parseInt(p, 10);
+      if (Number.isNaN(power)) {
+        return undefined;
+      }
+      return power;
+    })
+    .filter((power) => typeof power === 'number');
 
   return sortPowers(powers);
 };
@@ -55,8 +50,8 @@ const DEFAULT_TAGS: PowerLevelTags = {
     name: 'Goku',
     color: '#ff6a00',
   },
-  102: {
-    name: 'Goku Reborn',
+  150: {
+    name: 'Co-Founder',
     color: '#ff6a7f',
   },
   101: {
@@ -81,7 +76,7 @@ const DEFAULT_TAGS: PowerLevelTags = {
   },
 };
 
-const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): PowerLevelTag => {
+const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): MemberPowerTag => {
   const highToLow = sortPowers(getPowers(powerLevelTags));
 
   const tagPower = highToLow.find((p) => p < power);
@@ -92,12 +87,7 @@ const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): Pow
   };
 };
 
-export type GetPowerLevelTag = (powerLevel: number) => PowerLevelTag;
-
-export const usePowerLevelTags = (
-  room: Room,
-  powerLevels: IPowerLevels
-): [PowerLevelTags, GetPowerLevelTag] => {
+export const usePowerLevelTags = (room: Room, powerLevels: IPowerLevels): PowerLevelTags => {
   const tagsEvent = useStateEvent(room, StateEvent.PowerLevelTags);
 
   const powerLevelTags: PowerLevelTags = useMemo(() => {
@@ -114,66 +104,13 @@ export const usePowerLevelTags = (
     return powerToTags;
   }, [powerLevels, tagsEvent]);
 
-  const getTag: GetPowerLevelTag = useCallback(
-    (power) => {
-      const tag: PowerLevelTag | undefined = powerLevelTags[power];
-      return tag ?? generateFallbackTag(DEFAULT_TAGS, power);
-    },
-    [powerLevelTags]
-  );
-
-  return [powerLevelTags, getTag];
+  return powerLevelTags;
 };
 
-export const useFlattenPowerLevelTagMembers = (
-  members: RoomMember[],
-  getPowerLevel: (userId: string) => number,
-  getTag: GetPowerLevelTag
-): Array<PowerLevelTag | RoomMember> => {
-  const PLTagOrRoomMember = useMemo(() => {
-    let prevTag: PowerLevelTag | undefined;
-    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
-    members.forEach((member) => {
-      const memberPL = getPowerLevel(member.userId);
-      const tag = getTag(memberPL);
-      if (tag !== prevTag) {
-        prevTag = tag;
-        tagOrMember.push(tag);
-      }
-      tagOrMember.push(member);
-    });
-    return tagOrMember;
-  }, [members, getTag, getPowerLevel]);
-
-  return PLTagOrRoomMember;
-};
-
-export const getTagIconSrc = (
-  mx: MatrixClient,
-  useAuthentication: boolean,
-  icon: PowerLevelTagIcon
-): string | undefined =>
-  icon?.key?.startsWith('mxc://')
-    ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
-    : icon?.key;
-
-export const useAccessibleTagColors = (
-  themeKind: ThemeKind,
-  powerLevelTags: PowerLevelTags
-): Map<string, string> => {
-  const accessibleColors: Map<string, string> = useMemo(() => {
-    const colors: Map<string, string> = new Map();
-
-    getPowers(powerLevelTags).forEach((power) => {
-      const tag = powerLevelTags[power];
-      const { color } = tag;
-      if (!color) return;
-
-      colors.set(color, accessibleColor(themeKind, color));
-    });
-
-    return colors;
-  }, [powerLevelTags, themeKind]);
-
-  return accessibleColors;
+export const getPowerLevelTag = (
+  powerLevelTags: PowerLevelTags,
+  powerLevel: number
+): MemberPowerTag => {
+  const tag: MemberPowerTag | undefined = powerLevelTags[powerLevel];
+  return tag ?? generateFallbackTag(powerLevelTags, powerLevel);
 };
index 8bf8b74743dafdf491e3956b26d75bc1f7beee3c..0281b23c3184e06406ca8d8eb37cd55d058aa091 100644 (file)
@@ -58,10 +58,11 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
   });
 
 const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
-  const pl = mEvent?.getContent<IPowerLevels>();
-  if (!pl) return DEFAULT_POWER_LEVELS;
+  const plContent = mEvent?.getContent<IPowerLevels>();
 
-  return fillMissingPowers(pl);
+  const powerLevels = !plContent ? DEFAULT_POWER_LEVELS : fillMissingPowers(plContent);
+
+  return powerLevels;
 };
 
 export function usePowerLevels(room: Room): IPowerLevels {
@@ -120,33 +121,8 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
   return roomToPowerLevels;
 };
 
-export type GetPowerLevel = (powerLevels: IPowerLevels, userId: string | undefined) => number;
-export type CanSend = (
-  powerLevels: IPowerLevels,
-  eventType: string | undefined,
-  powerLevel: number
-) => boolean;
-export type CanDoAction = (
-  powerLevels: IPowerLevels,
-  action: PowerLevelActions,
-  powerLevel: number
-) => boolean;
-export type CanDoNotificationAction = (
-  powerLevels: IPowerLevels,
-  action: PowerLevelNotificationsAction,
-  powerLevel: number
-) => boolean;
-
-export type PowerLevelsAPI = {
-  getPowerLevel: GetPowerLevel;
-  canSendEvent: CanSend;
-  canSendStateEvent: CanSend;
-  canDoAction: CanDoAction;
-  canDoNotificationAction: CanDoNotificationAction;
-};
-
 export type ReadPowerLevelAPI = {
-  user: GetPowerLevel;
+  user: (powerLevels: IPowerLevels, userId: string | undefined) => number;
   event: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
   state: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
   action: (powerLevels: IPowerLevels, action: PowerLevelActions) => number;
@@ -156,6 +132,7 @@ export type ReadPowerLevelAPI = {
 export const readPowerLevel: ReadPowerLevelAPI = {
   user: (powerLevels, userId) => {
     const { users_default: usersDefault, users } = powerLevels;
+
     if (userId && users && typeof users[userId] === 'number') {
       return users[userId];
     }
@@ -191,63 +168,13 @@ export const readPowerLevel: ReadPowerLevelAPI = {
   },
 };
 
-export const powerLevelAPI: PowerLevelsAPI = {
-  getPowerLevel: (powerLevels, userId) => readPowerLevel.user(powerLevels, userId),
-  canSendEvent: (powerLevels, eventType, powerLevel) => {
-    const requiredPL = readPowerLevel.event(powerLevels, eventType);
-    return powerLevel >= requiredPL;
-  },
-  canSendStateEvent: (powerLevels, eventType, powerLevel) => {
-    const requiredPL = readPowerLevel.state(powerLevels, eventType);
-    return powerLevel >= requiredPL;
-  },
-  canDoAction: (powerLevels, action, powerLevel) => {
-    const requiredPL = readPowerLevel.action(powerLevels, action);
-    return powerLevel >= requiredPL;
-  },
-  canDoNotificationAction: (powerLevels, action, powerLevel) => {
-    const requiredPL = readPowerLevel.notification(powerLevels, action);
-    return powerLevel >= requiredPL;
-  },
-};
-
-export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
-  const getPowerLevel = useCallback(
-    (userId: string | undefined) => powerLevelAPI.getPowerLevel(powerLevels, userId),
-    [powerLevels]
-  );
-
-  const canSendEvent = useCallback(
-    (eventType: string | undefined, powerLevel: number) =>
-      powerLevelAPI.canSendEvent(powerLevels, eventType, powerLevel),
-    [powerLevels]
-  );
-
-  const canSendStateEvent = useCallback(
-    (eventType: string | undefined, powerLevel: number) =>
-      powerLevelAPI.canSendStateEvent(powerLevels, eventType, powerLevel),
-    [powerLevels]
-  );
-
-  const canDoAction = useCallback(
-    (action: PowerLevelActions, powerLevel: number) =>
-      powerLevelAPI.canDoAction(powerLevels, action, powerLevel),
-    [powerLevels]
-  );
-
-  const canDoNotificationAction = useCallback(
-    (action: PowerLevelNotificationsAction, powerLevel: number) =>
-      powerLevelAPI.canDoNotificationAction(powerLevels, action, powerLevel),
+export const useGetMemberPowerLevel = (powerLevels: IPowerLevels) => {
+  const callback = useCallback(
+    (userId?: string): number => readPowerLevel.user(powerLevels, userId),
     [powerLevels]
   );
 
-  return {
-    getPowerLevel,
-    canSendEvent,
-    canSendStateEvent,
-    canDoAction,
-    canDoNotificationAction,
-  };
+  return callback;
 };
 
 /**
diff --git a/src/app/hooks/useRoomCreators.ts b/src/app/hooks/useRoomCreators.ts
new file mode 100644 (file)
index 0000000..269d11a
--- /dev/null
@@ -0,0 +1,49 @@
+import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { useStateEvent } from './useStateEvent';
+import { IRoomCreateContent, StateEvent } from '../../types/matrix/room';
+import { creatorsSupported } from '../utils/matrix';
+import { getStateEvent } from '../utils/room';
+
+export const getRoomCreators = (createEvent: MatrixEvent): Set<string> => {
+  const createContent = createEvent.getContent<IRoomCreateContent>();
+
+  const creators: Set<string> = new Set();
+
+  if (!creatorsSupported(createContent.room_version)) return creators;
+
+  if (createEvent.event.sender) {
+    creators.add(createEvent.event.sender);
+  }
+
+  if ('additional_creators' in createContent && Array.isArray(createContent.additional_creators)) {
+    createContent.additional_creators.forEach((creator) => {
+      if (typeof creator === 'string') {
+        creators.add(creator);
+      }
+    });
+  }
+
+  return creators;
+};
+
+export const useRoomCreators = (room: Room): Set<string> => {
+  const createEvent = useStateEvent(room, StateEvent.RoomCreate);
+
+  const creators = useMemo(
+    () => (createEvent ? getRoomCreators(createEvent) : new Set<string>()),
+    [createEvent]
+  );
+
+  return creators;
+};
+
+export const getRoomCreatorsForRoomId = (mx: MatrixClient, roomId: string): Set<string> => {
+  const room = mx.getRoom(roomId);
+  if (!room) return new Set();
+
+  const createEvent = getStateEvent(room, StateEvent.RoomCreate);
+  if (!createEvent) return new Set();
+
+  return getRoomCreators(createEvent);
+};
diff --git a/src/app/hooks/useRoomCreatorsTag.ts b/src/app/hooks/useRoomCreatorsTag.ts
new file mode 100644 (file)
index 0000000..2d6db0e
--- /dev/null
@@ -0,0 +1,8 @@
+import { MemberPowerTag } from '../../types/matrix/room';
+
+const DEFAULT_TAG: MemberPowerTag = {
+  name: 'Founder',
+  color: '#0000ff',
+};
+
+export const useRoomCreatorsTag = (): MemberPowerTag => DEFAULT_TAG;
diff --git a/src/app/hooks/useRoomPermissions.ts b/src/app/hooks/useRoomPermissions.ts
new file mode 100644 (file)
index 0000000..cb6f69a
--- /dev/null
@@ -0,0 +1,60 @@
+import { useMemo } from 'react';
+import {
+  IPowerLevels,
+  PowerLevelActions,
+  PowerLevelNotificationsAction,
+  readPowerLevel,
+} from './usePowerLevels';
+
+export type RoomPermissionsAPI = {
+  event: (type: string, userId: string) => boolean;
+  stateEvent: (type: string, userId: string) => boolean;
+  action: (action: PowerLevelActions, userId: string) => boolean;
+  notificationAction: (action: PowerLevelNotificationsAction, userId: string) => boolean;
+};
+
+export const getRoomPermissionsAPI = (
+  creators: Set<string>,
+  powerLevels: IPowerLevels
+): RoomPermissionsAPI => {
+  const api: RoomPermissionsAPI = {
+    event: (type, userId) => {
+      if (creators.has(userId)) return true;
+      const userPower = readPowerLevel.user(powerLevels, userId);
+      const requiredPL = readPowerLevel.event(powerLevels, type);
+      return userPower >= requiredPL;
+    },
+    stateEvent: (type, userId) => {
+      if (creators.has(userId)) return true;
+      const userPower = readPowerLevel.user(powerLevels, userId);
+      const requiredPL = readPowerLevel.state(powerLevels, type);
+      return userPower >= requiredPL;
+    },
+    action: (action, userId) => {
+      if (creators.has(userId)) return true;
+      const userPower = readPowerLevel.user(powerLevels, userId);
+      const requiredPL = readPowerLevel.action(powerLevels, action);
+      return userPower >= requiredPL;
+    },
+    notificationAction: (action, userId) => {
+      if (creators.has(userId)) return true;
+      const userPower = readPowerLevel.user(powerLevels, userId);
+      const requiredPL = readPowerLevel.notification(powerLevels, action);
+      return userPower >= requiredPL;
+    },
+  };
+
+  return api;
+};
+
+export const useRoomPermissions = (
+  creators: Set<string>,
+  powerLevels: IPowerLevels
+): RoomPermissionsAPI => {
+  const api: RoomPermissionsAPI = useMemo(
+    () => getRoomPermissionsAPI(creators, powerLevels),
+    [creators, powerLevels]
+  );
+
+  return api;
+};
index a49577438be8a4f469031cc3cd91d9a6bfb248e2..afdfec6dce9733ed341b53c28b6341843d95f100 100644 (file)
@@ -84,16 +84,19 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 import { BackRouteHandler } from '../../../components/BackRouteHandler';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 import { allRoomsAtom } from '../../../state/room-list/roomList';
-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 { mDirectAtom } from '../../../state/mDirectList';
+import {
+  getPowerTagIconSrc,
+  useAccessiblePowerTagColors,
+  useGetMemberPowerTag,
+} from '../../../hooks/useMemberPowerTag';
+import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
 
 type RoomNotificationsGroup = {
   roomId: string;
@@ -224,10 +227,14 @@ function RoomNotificationsGroupComp({
   const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
 
   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();
@@ -447,13 +454,12 @@ function RoomNotificationsGroupComp({
           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;
@@ -523,8 +529,7 @@ function RoomNotificationsGroupComp({
                     replyEventId={replyEventId}
                     threadRootId={threadRootId}
                     onClick={handleOpenClick}
-                    getPowerLevel={getPowerLevel}
-                    getPowerLevelTag={getPowerLevelTag}
+                    getMemberPowerTag={getMemberPowerTag}
                     accessibleTagColors={accessibleTagColors}
                     legacyUsernameColor={legacyUsernameColor}
                   />
index 011741eee24435a77a5389ec4b4e4dec25a957e0..3ee6c725e52c5b15198f271379b2d321dbafde61 100644 (file)
@@ -77,7 +77,7 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData';
 import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
 import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { usePowerLevels } from '../../../hooks/usePowerLevels';
 import { useRoomsUnread } from '../../../state/hooks/unread';
 import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
 import { markAsRead } from '../../../../client/action/notifications';
@@ -91,6 +91,8 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 import { useSetting } from '../../../state/hooks/settings';
 import { settingsAtom } from '../../../state/settings';
 import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
 
 type SpaceMenuProps = {
   room: Room;
@@ -103,8 +105,10 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
     const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
     const roomToParents = useAtomValue(roomToParentsAtom);
     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 openSpaceSettings = useOpenSpaceSettings();
 
     const allChild = useSpaceChildren(
index d100946469053df75ac2cd45e086b2d3217dbf41..b657f73efa96a0146832157defd9b78b3df201e9 100644 (file)
@@ -10,6 +10,7 @@ import { useAtom, useAtomValue } from 'jotai';
 import {
   Avatar,
   Box,
+  Button,
   Icon,
   IconButton,
   Icons,
@@ -18,7 +19,9 @@ import {
   MenuItem,
   PopOut,
   RectCords,
+  Spinner,
   Text,
+  color,
   config,
   toRem,
 } from 'folds';
@@ -53,7 +56,7 @@ import { useRoomName } from '../../../hooks/useRoomMeta';
 import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
 import { allRoomsAtom } from '../../../state/room-list/roomList';
 import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
-import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { usePowerLevels } from '../../../hooks/usePowerLevels';
 import { openInviteUser } from '../../../../client/action/navigation';
 import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
 import { roomToParentsAtom } from '../../../state/room/roomToParents';
@@ -64,7 +67,7 @@ import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
 import { copyToClipboard } from '../../../utils/dom';
 import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
 import { useStateEvent } from '../../../hooks/useStateEvent';
-import { StateEvent } from '../../../../types/matrix/room';
+import { Membership, StateEvent } from '../../../../types/matrix/room';
 import { stopPropagation } from '../../../utils/keyboard';
 import { getMatrixToRoom } from '../../../plugins/matrix-to';
 import { getViaServers } from '../../../plugins/via-servers';
@@ -76,6 +79,11 @@ import {
 } from '../../../hooks/useRoomsNotificationPreferences';
 import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
 import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { BreakWord } from '../../../styles/Text.css';
 
 type SpaceMenuProps = {
   room: Room;
@@ -87,8 +95,10 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
   const [developerTools] = useSetting(settingsAtom, 'developerTools');
   const roomToParents = useAtomValue(roomToParentsAtom);
   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 openSpaceSettings = useOpenSpaceSettings();
   const { navigateRoom } = useRoomNavigate();
 
@@ -284,6 +294,75 @@ function SpaceHeader() {
   );
 }
 
+type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
+export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
+  const mx = useMatrixClient();
+  const { navigateRoom } = useRoomNavigate();
+
+  const [joinState, handleJoin] = useAsyncCallback(
+    useCallback(() => {
+      const currentRoom = mx.getRoom(roomId);
+      const via = currentRoom ? getViaServers(currentRoom) : [];
+      return mx.joinRoom(replacementRoomId, {
+        viaServers: via,
+      });
+    }, [mx, roomId, replacementRoomId])
+  );
+  const replacementRoom = mx.getRoom(replacementRoomId);
+
+  const handleOpen = () => {
+    if (replacementRoom) navigateRoom(replacementRoom.roomId);
+    if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
+  };
+
+  return (
+    <Box
+      style={{
+        padding: config.space.S200,
+        borderRadius: config.radii.R400,
+        borderWidth: config.borderWidth.B300,
+      }}
+      className={ContainerColor({ variant: 'Surface' })}
+      direction="Column"
+      gap="300"
+    >
+      <Box direction="Column" grow="Yes" gap="100">
+        <Text size="L400">Space Upgraded</Text>
+        <Text size="T200">This space has been replaced and is no longer active.</Text>
+        {joinState.status === AsyncStatus.Error && (
+          <Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
+            {(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
+          </Text>
+        )}
+      </Box>
+      <Box direction="Column" shrink="No">
+        {replacementRoom?.getMyMembership() === Membership.Join ||
+        joinState.status === AsyncStatus.Success ? (
+          <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
+            <Text size="B300">Open New Space</Text>
+          </Button>
+        ) : (
+          <Button
+            onClick={handleJoin}
+            size="300"
+            variant="Primary"
+            fill="Solid"
+            radii="300"
+            before={
+              joinState.status === AsyncStatus.Loading && (
+                <Spinner size="100" variant="Primary" fill="Solid" />
+              )
+            }
+            disabled={joinState.status === AsyncStatus.Loading}
+          >
+            <Text size="B300">Join New Space</Text>
+          </Button>
+        )}
+      </Box>
+    </Box>
+  );
+}
+
 export function Space() {
   const mx = useMatrixClient();
   const space = useSpace();
@@ -296,6 +375,8 @@ export function Space() {
   const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
   const notificationPreferences = useRoomsNotificationPreferencesContext();
 
+  const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
+
   const selectedRoomId = useSelectedRoom();
   const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
   const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
@@ -351,6 +432,12 @@ export function Space() {
       <SpaceHeader />
       <PageNavContent scrollRef={scrollRef}>
         <Box direction="Column" gap="300">
+          {tombstoneEvent && (
+            <SpaceTombstone
+              roomId={space.roomId}
+              replacementRoomId={tombstoneEvent.getContent().replacement_room}
+            />
+          )}
           <NavCategory>
             <NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
               <NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
index b31677a0f06ed9c0fb8e744ab4a2d94f39435588..c8b104d987767192f596aa455c78011031062cc1 100644 (file)
@@ -357,3 +357,7 @@ export const knockRestrictedSupported = (version: string): boolean => {
   const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
   return !unsupportedVersion.includes(version);
 };
+export const creatorsSupported = (version: string): boolean => {
+  const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
+  return !unsupportedVersion.includes(version);
+};
index 20ce9419118e9d0bcc6078e50e94f4af2f57aaef..9871599642e59e7834a686d49158bfe310bd3dcf 100644 (file)
@@ -18,6 +18,8 @@ export enum AccountDataEvent {
   MegolmBackupV1 = 'm.megolm_backup.v1',
 }
 
+export type MDirectContent = Record<string, string[]>;
+
 export type SecretStorageDefaultKeyContent = {
   key: string;
 };
index 65dc35f4b17e7ae3846a0656588def0337b1d6df..f0927b3cf4b5ec8741a381398cca65f0a3221040 100644 (file)
@@ -1,3 +1,5 @@
+import { IImageInfo } from './common';
+
 export enum Membership {
   Invite = 'invite',
   Knock = 'knock',
@@ -69,7 +71,7 @@ export type IRoomCreateContent = {
   room_version: string;
   type?: string;
   predecessor?: {
-    event_id: string;
+    event_id?: string;
     room_id: string;
   };
 };
@@ -93,3 +95,13 @@ export type MuteChanges = {
   added: string[];
   removed: string[];
 };
+
+export type MemberPowerTagIcon = {
+  key?: string;
+  info?: IImageInfo;
+};
+export type MemberPowerTag = {
+  name: string;
+  color?: string;
+  icon?: MemberPowerTagIcon;
+};