Make dialog to add existing rooms to space
authorAjay Bura <ajbura@gmail.com>
Sun, 20 Feb 2022 14:47:13 +0000 (20:17 +0530)
committerAjay Bura <ajbura@gmail.com>
Sun, 20 Feb 2022 14:47:13 +0000 (20:17 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
src/app/molecules/space-add-existing/SpaceAddExisting.jsx [new file with mode: 0644]
src/app/molecules/space-add-existing/SpaceAddExisting.scss [new file with mode: 0644]
src/app/organisms/navigation/DrawerHeader.jsx
src/app/organisms/pw/Dialogs.jsx
src/client/action/navigation.js
src/client/state/cons.js
src/client/state/navigation.js

diff --git a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx
new file mode 100644 (file)
index 0000000..5bbb1da
--- /dev/null
@@ -0,0 +1,202 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './SpaceAddExisting.scss';
+
+import { twemojify } from '../../../util/twemojify';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+import { joinRuleToIconSrc } from '../../../util/matrixUtil';
+import { Debounce } from '../../../util/common';
+
+import Text from '../../atoms/text/Text';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import Checkbox from '../../atoms/button/Checkbox';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+import RoomSelector from '../room-selector/RoomSelector';
+import Dialog from '../dialog/Dialog';
+
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+import SearchIC from '../../../../public/res/ic/outlined/search.svg';
+
+function SpaceAddExistingContent({ roomId }) {
+  const [debounce] = useState(new Debounce());
+  const [process, setProcess] = useState(null);
+  const [selected, setSelected] = useState([]);
+  const [searchIds, setSearchIds] = useState(null);
+  const mx = initMatrix.matrixClient;
+  const {
+    spaces, rooms, directs, roomIdToParents,
+  } = initMatrix.roomList;
+
+  let allRoomIds = [...spaces, ...rooms, ...directs];
+  allRoomIds = allRoomIds.filter((rId) => (
+    rId !== roomId
+    && !roomIdToParents.get(rId)?.has(roomId)
+  ));
+
+  const toggleSelection = (rId) => {
+    if (process !== null) return;
+    const newSelected = [...selected];
+    const selectedIndex = newSelected.indexOf(rId);
+
+    if (selectedIndex > -1) {
+      newSelected.splice(selectedIndex, 1);
+      setSelected(newSelected);
+      return;
+    }
+    newSelected.push(rId);
+    setSelected(newSelected);
+  };
+
+  const handleSearch = (ev) => {
+    const term = ev.target.value.toLocaleLowerCase().replaceAll(' ', '');
+    if (term === '') {
+      setSearchIds(null);
+      return;
+    }
+
+    debounce._(() => {
+      const searchedIds = allRoomIds.filter((rId) => {
+        let name = mx.getRoom(rId)?.name;
+        if (!name) return false;
+        name = name.normalize('NFKC')
+          .toLocaleLowerCase()
+          .replaceAll(' ', '');
+        return name.includes(term);
+      });
+      setSearchIds(searchedIds);
+    }, 400)();
+  };
+
+  const handleAdd = async () => {
+    setProcess(`Adding ${selected.length} items...`);
+  };
+
+  return (
+    <>
+      <form
+        onSubmit={(ev) => {
+          ev.preventDefault();
+          const { target } = ev;
+          target.searchInput.value = '';
+          setSearchIds(null);
+        }}
+      >
+        <RawIcon size="small" src={SearchIC} />
+        <Input
+          name="searchInput"
+          onChange={handleSearch}
+          placeholder="Search room"
+          autoFocus
+        />
+        <IconButton size="small" type="submit" src={CrossIC} />
+      </form>
+      {searchIds?.length === 0 && <Text>No result found</Text>}
+      {
+        (searchIds || allRoomIds).map((rId) => {
+          const room = mx.getRoom(rId);
+          let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+          if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+
+          const parentSet = roomIdToParents.get(rId);
+          const parentNames = parentSet
+            ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
+            : undefined;
+          const parents = parentNames ? parentNames.join(', ') : null;
+
+          const handleSelect = () => toggleSelection(rId);
+
+          return (
+            <RoomSelector
+              key={rId}
+              name={room.name}
+              parentName={parents}
+              roomId={rId}
+              imageSrc={directs.has(rId) ? imageSrc : null}
+              iconSrc={
+                directs.has(rId)
+                  ? null
+                  : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
+              }
+              isUnread={false}
+              notificationCount={0}
+              isAlert={false}
+              onClick={handleSelect}
+              options={(
+                <Checkbox
+                  isActive={selected.includes(rId)}
+                  variant="positive"
+                  onToggle={handleSelect}
+                  tabIndex={-1}
+                  disabled={process !== null}
+                />
+              )}
+            />
+          );
+        })
+      }
+      {selected.length !== 0 && (
+        <div className="space-add-existing__footer">
+          {process && <Spinner size="small" />}
+          <Text weight="medium">{process || `${selected.length} item selected`}</Text>
+          { !process && (
+            <Button onClick={handleAdd} variant="primary">Add</Button>
+          )}
+        </div>
+      )}
+    </>
+  );
+}
+SpaceAddExistingContent.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+function useVisibilityToggle() {
+  const [roomId, setRoomId] = useState(null);
+
+  useEffect(() => {
+    const handleOpen = (rId) => setRoomId(rId);
+    navigation.on(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
+    return () => {
+      navigation.removeListener(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
+    };
+  }, []);
+
+  const requestClose = () => setRoomId(null);
+
+  return [roomId, requestClose];
+}
+
+function SpaceAddExisting() {
+  const [roomId, requestClose] = useVisibilityToggle();
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+
+  return (
+    <Dialog
+      isOpen={roomId !== null}
+      className="space-add-existing"
+      title={(
+        <Text variant="s1" weight="medium" primary>
+          {roomId && twemojify(room.name)}
+          <span style={{ color: 'var(--tc-surface-low)' }}> — add existing rooms</span>
+        </Text>
+      )}
+      contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
+      onRequestClose={requestClose}
+    >
+      {
+        roomId
+          ? <SpaceAddExistingContent roomId={roomId} />
+          : <div />
+      }
+    </Dialog>
+  );
+}
+
+export default SpaceAddExisting;
diff --git a/src/app/molecules/space-add-existing/SpaceAddExisting.scss b/src/app/molecules/space-add-existing/SpaceAddExisting.scss
new file mode 100644 (file)
index 0000000..792cdc0
--- /dev/null
@@ -0,0 +1,74 @@
+@use '../../partials/dir';
+@use '../../partials/flex';
+
+.space-add-existing {
+  height: 100%;
+  
+  .dialog__content-container {
+    padding: 0;
+    padding-bottom: 80px;
+    @include dir.side(padding, var(--sp-extra-tight), 0);
+
+    & > .text {
+      margin: var(--sp-loose) var(--sp-normal);
+      text-align: center;
+    }
+  }
+
+  & form {
+    @extend .cp-fx__row--s-c;
+    padding: var(--sp-extra-tight);
+    padding-top: var(--sp-normal);
+
+    position: sticky;
+    top: 0;
+    z-index: 999;
+    background-color: var(--bg-surface);
+
+    & > .ic-raw,
+    & > .ic-btn {
+      position: absolute;
+    }
+    & > .ic-raw {
+      margin: 0 var(--sp-tight);
+    }
+    & > .ic-btn {
+      border-radius: calc(var(--bo-radius) / 2);
+      @include dir.prop(right, var(--sp-tight), unset);
+      @include dir.prop(left, unset, var(--sp-tight));
+    }
+    & input {
+      padding: var(--sp-tight) 40px;
+    }
+  }
+
+  .input-container {
+    @extend .cp-fx__item-one;
+  }
+
+  .room-selector__options {
+    display: flex;
+    margin: 0 10px;
+  }
+}
+
+.space-add-existing__footer {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  padding: var(--sp-normal);
+  background-color: var(--bg-surface);
+  border-top: 1px solid var(--bg-surface-border);
+  display: flex;
+  align-items: center;
+
+  & > .text {
+    @extend .cp-fx__item-one;
+    padding: 0 var(--sp-tight);
+  }
+
+  & > button {
+    @include dir.side(margin, var(--sp-normal), 0);
+  }
+}
\ No newline at end of file
index 198e61e8f92200a1c3d3c4683dfa35f7e1582c08..5052865c52538dfda6e65bffc62237f4a3c92e3e 100644 (file)
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import './DrawerHeader.scss';
 
@@ -7,9 +7,9 @@ import { twemojify } from '../../../util/twemojify';
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import {
-  openPublicRooms, openCreateRoom, openInviteUser, openReusableContextMenu,
+  openPublicRooms, openCreateRoom, openSpaceManage,
+  openSpaceAddExisting, openInviteUser, openReusableContextMenu,
 } from '../../../client/action/navigation';
-import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room';
 import { getEventCords } from '../../../util/common';
 
 import { blurOnBubbling } from '../../atoms/button/script';
@@ -18,24 +18,83 @@ import Text from '../../atoms/text/Text';
 import RawIcon from '../../atoms/system-icons/RawIcon';
 import Header, { TitleWrapper } from '../../atoms/header/Header';
 import IconButton from '../../atoms/button/IconButton';
-import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
 import SpaceOptions from '../../molecules/space-options/SpaceOptions';
 
 import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
 import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
 import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
+import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
+import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
 import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
-import PinIC from '../../../../public/res/ic/outlined/pin.svg';
-import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
+
+function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(spaceId);
+  const canManage = room
+    ? room.currentState.maySendStateEvent('m.space.child', mx.getUserId())
+    : true;
+
+  return (
+    <>
+      <MenuHeader>Add rooms or spaces</MenuHeader>
+      <MenuItem
+        iconSrc={HashPlusIC}
+        onClick={() => { afterOptionSelect(); openCreateRoom(); }}
+        disabled={!canManage}
+      >
+        Create new room
+      </MenuItem>
+      <MenuItem
+        iconSrc={SpacePlusIC}
+        onClick={() => { afterOptionSelect(); }}
+        disabled={!canManage}
+      >
+        Create new space
+      </MenuItem>
+      { !spaceId && (
+        <MenuItem
+          iconSrc={HashGlobeIC}
+          onClick={() => { afterOptionSelect(); openPublicRooms(); }}
+        >
+          Join public room
+        </MenuItem>
+      )}
+      { spaceId && (
+        <MenuItem
+          iconSrc={PlusIC}
+          onClick={() => { afterOptionSelect(); openSpaceAddExisting(spaceId); }}
+          disabled={!canManage}
+        >
+          Add existing
+        </MenuItem>
+      )}
+      { spaceId && (
+        <MenuItem
+          onClick={() => { afterOptionSelect(); openSpaceManage(spaceId); }}
+          iconSrc={HashSearchIC}
+        >
+          Manage rooms
+        </MenuItem>
+      )}
+    </>
+  );
+}
+HomeSpaceOptions.defaultProps = {
+  spaceId: null,
+};
+HomeSpaceOptions.propTypes = {
+  spaceId: PropTypes.string,
+  afterOptionSelect: PropTypes.func.isRequired,
+};
 
 function DrawerHeader({ selectedTab, spaceId }) {
-  const [, forceUpdate] = useState({});
   const mx = initMatrix.matrixClient;
-  const { spaceShortcut } = initMatrix.roomList;
   const tabName = selectedTab !== cons.tabs.DIRECTS ? 'Home' : 'Direct messages';
 
+  const isDMTab = selectedTab === cons.tabs.DIRECTS;
   const room = mx.getRoom(spaceId);
-  const spaceName = selectedTab === cons.tabs.DIRECTS ? null : (room?.name || null);
+  const spaceName = isDMTab ? null : (room?.name || null);
 
   const openSpaceOptions = (e) => {
     e.preventDefault();
@@ -46,6 +105,15 @@ function DrawerHeader({ selectedTab, spaceId }) {
     );
   };
 
+  const openHomeSpaceOptions = (e) => {
+    e.preventDefault();
+    openReusableContextMenu(
+      'right',
+      getEventCords(e, '.ic-btn'),
+      (closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
+    );
+  };
+
   return (
     <Header>
       {spaceName ? (
@@ -65,41 +133,9 @@ function DrawerHeader({ selectedTab, spaceId }) {
           <Text variant="s1" weight="medium" primary>{tabName}</Text>
         </TitleWrapper>
       )}
-      {spaceName && (
-        <IconButton
-          size="extra-small"
-          tooltip={spaceShortcut.has(spaceId) ? 'Unpin' : 'Pin to sidebar'}
-          src={spaceShortcut.has(spaceId) ? PinFilledIC : PinIC}
-          onClick={() => {
-            if (spaceShortcut.has(spaceId)) deleteSpaceShortcut(spaceId);
-            else createSpaceShortcut(spaceId);
-            forceUpdate({});
-          }}
-        />
-      )}
-      { selectedTab === cons.tabs.DIRECTS && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="normal" /> }
-      { selectedTab !== cons.tabs.DIRECTS && !spaceName && (
-        <ContextMenu
-          content={(hideMenu) => (
-            <>
-              <MenuHeader>Add room</MenuHeader>
-              <MenuItem
-                iconSrc={HashPlusIC}
-                onClick={() => { hideMenu(); openCreateRoom(); }}
-              >
-                Create new room
-              </MenuItem>
-              <MenuItem
-                iconSrc={HashGlobeIC}
-                onClick={() => { hideMenu(); openPublicRooms(); }}
-              >
-                Join public room
-              </MenuItem>
-            </>
-          )}
-          render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add room" src={PlusIC} size="normal" />)}
-        />
-      )}
+
+      { isDMTab && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="small" /> }
+      { !isDMTab && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="small" /> }
     </Header>
   );
 }
index 0fa89cb432f0fbc12dbde8f8079f63ee50b52d39..b5038e5650a72fbced1ed709d106fbba131f3a5d 100644 (file)
@@ -2,6 +2,7 @@ import React from 'react';
 
 import ReadReceipts from '../read-receipts/ReadReceipts';
 import ProfileViewer from '../profile-viewer/ProfileViewer';
+import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
 import Search from '../search/Search';
 
 function Dialogs() {
@@ -9,6 +10,7 @@ function Dialogs() {
     <>
       <ReadReceipts />
       <ProfileViewer />
+      <SpaceAddExisting />
       <Search />
     </>
   );
index 6f82a42b26dff0ab1e834a9ea0d616bda81ba382..964a3f5b6df6710c2891f5aa1d483d0e4412525d 100644 (file)
@@ -38,6 +38,13 @@ export function openSpaceManage(roomId) {
   });
 }
 
+export function openSpaceAddExisting(roomId) {
+  appDispatcher.dispatch({
+    type: cons.actions.navigation.OPEN_SPACE_ADDEXISTING,
+    roomId,
+  });
+}
+
 export function toggleRoomSettings(tabText) {
   appDispatcher.dispatch({
     type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS,
index 2ac4cbdde8237a8e352776edc1e3bdcdfda329c4..2d20dbeebef49c2ba39ba44b01cd7d2fc54288aa 100644 (file)
@@ -32,6 +32,7 @@ const cons = {
       SELECT_ROOM: 'SELECT_ROOM',
       OPEN_SPACE_SETTINGS: 'OPEN_SPACE_SETTINGS',
       OPEN_SPACE_MANAGE: 'OPEN_SPACE_MANAGE',
+      OPEN_SPACE_ADDEXISTING: 'OPEN_SPACE_ADDEXISTING',
       TOGGLE_ROOM_SETTINGS: 'TOGGLE_ROOM_SETTINGS',
       OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
       OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
@@ -71,6 +72,7 @@ const cons = {
       ROOM_SELECTED: 'ROOM_SELECTED',
       SPACE_SETTINGS_OPENED: 'SPACE_SETTINGS_OPENED',
       SPACE_MANAGE_OPENED: 'SPACE_MANAGE_OPENED',
+      SPACE_ADDEXISTING_OPENED: 'SPACE_ADDEXISTING_OPENED',
       ROOM_SETTINGS_TOGGLED: 'ROOM_SETTINGS_TOGGLED',
       INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
       PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED',
index a7044f596ba49e5f19d1e529d55ea844d5357755..52d5f1504e662fd794da5f689c218e579fd35c7a 100644 (file)
@@ -95,6 +95,9 @@ class Navigation extends EventEmitter {
       [cons.actions.navigation.OPEN_SPACE_MANAGE]: () => {
         this.emit(cons.events.navigation.SPACE_MANAGE_OPENED, action.roomId);
       },
+      [cons.actions.navigation.OPEN_SPACE_ADDEXISTING]: () => {
+        this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId);
+      },
       [cons.actions.navigation.TOGGLE_ROOM_SETTINGS]: () => {
         this.isRoomSettings = !this.isRoomSettings;
         this.emit(