Add ability to manage room addresses
authorAjay Bura <ajbura@gmail.com>
Sat, 1 Jan 2022 06:13:35 +0000 (11:43 +0530)
committerAjay Bura <ajbura@gmail.com>
Sat, 1 Jan 2022 06:13:35 +0000 (11:43 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
src/app/atoms/context-menu/ContextMenu.jsx
src/app/molecules/room-aliases/RoomAliases.jsx [new file with mode: 0644]
src/app/molecules/room-aliases/RoomAliases.scss [new file with mode: 0644]
src/app/organisms/room/RoomSettings.jsx
src/util/matrixUtil.js

index 99dd9e6e420ee749bea069ada6151f19274ae6e0..7d1acd4406fe680c6da7ce8f153e63d8a87cafba 100644 (file)
@@ -91,16 +91,17 @@ function MenuItem({
 
 MenuItem.defaultProps = {
   variant: 'surface',
-  iconSrc: 'none',
+  iconSrc: null,
   type: 'button',
   disabled: false,
+  onClick: null,
 };
 
 MenuItem.propTypes = {
   variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
   iconSrc: PropTypes.string,
   type: PropTypes.oneOf(['button', 'submit']),
-  onClick: PropTypes.func.isRequired,
+  onClick: PropTypes.func,
   children: PropTypes.node.isRequired,
   disabled: PropTypes.bool,
 };
diff --git a/src/app/molecules/room-aliases/RoomAliases.jsx b/src/app/molecules/room-aliases/RoomAliases.jsx
new file mode 100644 (file)
index 0000000..f8896eb
--- /dev/null
@@ -0,0 +1,352 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomAliases.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { Debounce } from '../../../util/common';
+import { isRoomAliasAvailable } from '../../../util/matrixUtil';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Checkbox from '../../atoms/button/Checkbox';
+import Toggle from '../../atoms/button/Toggle';
+import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+
+import { useStore } from '../../hooks/useStore';
+
+function useValidate(hsString) {
+  const [debounce] = useState(new Debounce());
+  const [validate, setValidate] = useState({ alias: null, status: cons.status.PRE_FLIGHT });
+
+  const setValidateToDefault = () => {
+    setValidate({
+      alias: null,
+      status: cons.status.PRE_FLIGHT,
+    });
+  };
+
+  const checkValueOK = (value) => {
+    if (value.trim() === '') {
+      setValidateToDefault();
+      return false;
+    }
+    if (!value.match(/^[a-zA-Z0-9_-]+$/)) {
+      setValidate({
+        alias: null,
+        status: cons.status.ERROR,
+        msg: 'Invalid character: only letter, numbers and _- are allowed.',
+      });
+      return false;
+    }
+    return true;
+  };
+
+  const handleAliasChange = (e) => {
+    const input = e.target;
+    if (validate.status !== cons.status.PRE_FLIGHT) {
+      setValidateToDefault();
+    }
+    if (checkValueOK(input.value) === false) return;
+
+    debounce._(async () => {
+      const { value } = input;
+      const alias = `#${value}:${hsString}`;
+      if (checkValueOK(value) === false) return;
+
+      setValidate({
+        alias,
+        status: cons.status.IN_FLIGHT,
+        msg: `validating ${alias}...`,
+      });
+
+      const isValid = await isRoomAliasAvailable(alias);
+      setValidate(() => {
+        if (e.target.value !== value) {
+          return { alias: null, status: cons.status.PRE_FLIGHT };
+        }
+        return {
+          alias,
+          status: isValid ? cons.status.SUCCESS : cons.status.ERROR,
+          msg: isValid ? `${alias} is available.` : `${alias} is already in use.`,
+        };
+      });
+    }, 600)();
+  };
+
+  return [validate, setValidateToDefault, handleAliasChange];
+}
+
+function getAliases(roomId) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+
+  const main = room.getCanonicalAlias();
+  const published = room.getAltAliases();
+  if (main && !published.includes(main)) published.splice(0, 0, main);
+
+  return {
+    main,
+    published: [...new Set(published)],
+    local: [],
+  };
+}
+
+function RoomAliases({ roomId }) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+  const userId = mx.getUserId();
+  const hsString = userId.slice(userId.indexOf(':') + 1);
+
+  const isMountedStore = useStore();
+  const [isPublic, setIsPublic] = useState(false);
+  const [isLocalVisible, setIsLocalVisible] = useState(false);
+  const [aliases, setAliases] = useState(getAliases(roomId));
+  const [selectedAlias, setSelectedAlias] = useState(null);
+  const [deleteAlias, setDeleteAlias] = useState(null);
+  const [validate, setValidateToDefault, handleAliasChange] = useValidate(hsString);
+
+  const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId);
+
+  useEffect(() => isMountedStore.setItem(true), []);
+
+  useEffect(() => {
+    let isUnmounted = false;
+
+    const loadLocalAliases = async () => {
+      let local = [];
+      try {
+        const result = await mx.unstableGetLocalAliases(roomId);
+        local = result.aliases.filter((alias) => !aliases.published.includes(alias));
+      } catch {
+        local = [];
+      }
+      aliases.local = [...new Set(local.reverse())];
+
+      if (isUnmounted) return;
+      setAliases({ ...aliases });
+    };
+    const loadVisibility = async () => {
+      const result = await mx.getRoomDirectoryVisibility(roomId);
+      if (isUnmounted) return;
+      setIsPublic(result.visibility === 'public');
+    };
+    loadLocalAliases();
+    loadVisibility();
+    return () => {
+      isUnmounted = true;
+    };
+  }, [roomId]);
+
+  const toggleDirectoryVisibility = () => {
+    mx.setRoomDirectoryVisibility(roomId, isPublic ? 'private' : 'public');
+    setIsPublic(!isPublic);
+  };
+
+  const handleAliasSubmit = async (e) => {
+    e.preventDefault();
+    if (validate.status === cons.status.ERROR) return;
+    if (!validate.alias) return;
+
+    const { alias } = validate;
+    const aliasInput = e.target.elements['alias-input'];
+    aliasInput.value = '';
+    setValidateToDefault();
+
+    try {
+      aliases.local.push(alias);
+      setAliases({ ...aliases });
+      await mx.createAlias(alias, roomId);
+    } catch {
+      if (isMountedStore.getItem()) {
+        const lIndex = alias.local.indexOf(alias);
+        if (lIndex === -1) return;
+        aliases.local.splice(lIndex, 1);
+        setAliases({ ...aliases });
+      }
+    }
+  };
+
+  const handleAliasSelect = (alias) => {
+    setSelectedAlias(alias === selectedAlias ? null : alias);
+  };
+
+  const handlePublishAlias = (alias) => {
+    const { main, published } = aliases;
+    let { local } = aliases;
+
+    if (!published.includes(aliases)) {
+      published.push(alias);
+      local = local.filter((al) => al !== alias);
+      mx.sendStateEvent(roomId, 'm.room.canonical_alias', {
+        alias: main,
+        alt_aliases: published.filter((al) => al !== main),
+      });
+      setAliases({ main, published, local });
+      setSelectedAlias(null);
+    }
+  };
+
+  const handleUnPublishAlias = (alias) => {
+    let { main, published } = aliases;
+    const { local } = aliases;
+
+    if (published.includes(alias) || main === alias) {
+      if (main === alias) main = null;
+      published = published.filter((al) => al !== alias);
+      local.push(alias);
+      mx.sendStateEvent(roomId, 'm.room.canonical_alias', {
+        alias: main,
+        alt_aliases: published.filter((al) => al !== main),
+      });
+      setAliases({ main, published, local });
+      setSelectedAlias(null);
+    }
+  };
+
+  const handleSetMainAlias = (alias) => {
+    let { main, local } = aliases;
+    const { published } = aliases;
+
+    if (main !== alias) {
+      main = alias;
+      if (!published.includes(alias)) published.splice(0, 0, alias);
+      local = local.filter((al) => al !== alias);
+      mx.sendStateEvent(roomId, 'm.room.canonical_alias', {
+        alias: main,
+        alt_aliases: published.filter((al) => al !== main),
+      });
+      setAliases({ main, published, local });
+      setSelectedAlias(null);
+    }
+  };
+
+  const handleDeleteAlias = async (alias) => {
+    try {
+      setDeleteAlias({ alias, status: cons.status.IN_FLIGHT, msg: 'deleting...' });
+      await mx.deleteAlias(alias);
+      let { main, published, local } = aliases;
+      if (published.includes(alias)) {
+        handleUnPublishAlias(alias);
+        if (main === alias) main = null;
+        published = published.filter((al) => al !== alias);
+      }
+
+      local = local.filter((al) => al !== alias);
+      setAliases({ main, published, local });
+      setDeleteAlias(null);
+      setSelectedAlias(null);
+    } catch (err) {
+      setDeleteAlias({ alias, status: cons.status.ERROR, msg: err.message });
+    }
+  };
+
+  const renderAliasBtns = (alias) => {
+    const isPublished = aliases.published.includes(alias);
+    const isMain = aliases.main === alias;
+    if (deleteAlias?.alias === alias) {
+      const isError = deleteAlias.status === cons.status.ERROR;
+      return (
+        <div className="room-aliases__item-btns">
+          <Text variant="b2">
+            <span style={{ color: isError ? 'var(--tc-danger-high' : 'inherit' }}>{deleteAlias.msg}</span>
+          </Text>
+        </div>
+      );
+    }
+
+    return (
+      <div className="room-aliases__item-btns">
+        {canPublishAlias && !isMain && <Button onClick={() => handleSetMainAlias(alias)} variant="primary">Set as Main</Button>}
+        {!isPublished && canPublishAlias && <Button onClick={() => handlePublishAlias(alias)} variant="positive">Publish</Button>}
+        {isPublished && canPublishAlias && <Button onClick={() => handleUnPublishAlias(alias)} variant="caution">Un-Publish</Button>}
+        <Button onClick={() => handleDeleteAlias(alias)} variant="danger">Delete</Button>
+      </div>
+    );
+  };
+
+  const renderAlias = (alias) => {
+    const isActive = selectedAlias === alias;
+    const disabled = !canPublishAlias && aliases.published.includes(alias);
+    const isMain = aliases.main === alias;
+
+    return (
+      <React.Fragment key={`${alias}-wrapper`}>
+        <div className="room-aliases__alias-item" key={alias}>
+          <Checkbox variant="positive" disabled={disabled} isActive={isActive} onToggle={() => handleAliasSelect(alias)} />
+          <Text>
+            {alias}
+            {isMain && <span>Main</span>}
+          </Text>
+        </div>
+        {isActive && renderAliasBtns(alias)}
+      </React.Fragment>
+    );
+  };
+
+  let inputState = 'normal';
+  if (validate.status === cons.status.ERROR) inputState = 'error';
+  if (validate.status === cons.status.SUCCESS) inputState = 'success';
+  return (
+    <div className="room-aliases">
+      <SettingTile
+        title="Publish to room directory"
+        content={<Text variant="b3">{`Publish this room to the ${hsString}'s public room directory?`}</Text>}
+        options={(
+          <Toggle
+            isActive={isPublic}
+            onToggle={toggleDirectoryVisibility}
+            disabled={!canPublishAlias}
+          />
+        )}
+      />
+
+      <div className="room-aliases__content">
+        <MenuHeader>Published addresses</MenuHeader>
+        {(aliases.published.length === 0) && <Text className="room-aliases__message">No published addresses</Text>}
+        {(aliases.published.length > 0 && !aliases.main) && <Text className="room-aliases__message">No Main address (select one from below)</Text>}
+        {aliases.published.map(renderAlias)}
+        <Text className="room-aliases__message" variant="b3">Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.</Text>
+      </div>
+      { isLocalVisible && (
+        <div className="room-aliases__content">
+          <MenuHeader>Local addresses</MenuHeader>
+          {(aliases.local.length === 0) && <Text className="room-aliases__message">No local addresses</Text>}
+          {aliases.local.map(renderAlias)}
+          <Text className="room-aliases__message" variant="b3">Set local addresses for this room so users can find this room through your homeserver.</Text>
+
+          <Text className="room-aliases__form-label" variant="b2">Add local address</Text>
+          <form className="room-aliases__form" onSubmit={handleAliasSubmit}>
+            <div className="room-aliases__input-wrapper">
+              <Input
+                name="alias-input"
+                state={inputState}
+                onChange={handleAliasChange}
+                placeholder="my_room_address"
+                required
+              />
+            </div>
+            <Button variant="primary" type="submit">Add</Button>
+          </form>
+          <div className="room-aliases__input-status">
+            {validate.status === cons.status.SUCCESS && <Text className="room-aliases__valid" variant="b2">{validate.msg}</Text>}
+            {validate.status === cons.status.ERROR && <Text className="room-aliases__invalid" variant="b2">{validate.msg}</Text>}
+          </div>
+        </div>
+      )}
+      <div className="room-aliases__content">
+        <Button onClick={() => setIsLocalVisible(!isLocalVisible)}>
+          {`${isLocalVisible ? 'Hide' : 'Add / View'} local address`}
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+RoomAliases.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+export default RoomAliases;
diff --git a/src/app/molecules/room-aliases/RoomAliases.scss b/src/app/molecules/room-aliases/RoomAliases.scss
new file mode 100644 (file)
index 0000000..b0df614
--- /dev/null
@@ -0,0 +1,84 @@
+@use '../../partials/flex';
+@use '../../partials/dir';
+@use '../../partials/text';
+
+.room-aliases {
+  &__message,
+  & .setting-tile {
+    margin: var(--sp-tight) var(--sp-normal);
+  }
+  & .setting-tile {
+    margin-bottom: var(--sp-loose);
+  }
+
+  &__alias-item {
+    padding: var(--sp-extra-tight) var(--sp-normal);
+    @extend .cp-fx__row--s-c;
+    &.checkbox {
+      @include dir.side(margin, 0 , var(--sp-tight));
+    }
+    & .text {
+      @extend .cp-fx__item-one;
+      @extend .cp-txt__ellipsis;
+      color: var(--tc-surface-high);
+      span {
+        margin: 0 var(--sp-extra-tight);
+        padding: 0 var(--sp-ultra-tight);
+        color: var(--bg-surface);
+        background-color: var(--tc-surface-low);
+        border-radius: 4px;
+      }
+    }
+  }
+  &__item-btns {
+    @include dir.side(margin, 48px, 0);
+    & button {
+      padding: var(--sp-ultra-tight) var(--sp-tight);
+      margin-bottom: var(--sp-tight);
+      @include dir.side(margin, 0, var(--sp-tight));
+    }
+  }
+
+  &__content {
+    margin-bottom: var(--sp-normal);
+
+    & .checkbox {
+      @include dir.side(margin, 0, var(--sp-tight));
+      min-width: 20px;
+    }
+    & > button {
+      margin: 0 var(--sp-normal);
+    }
+  }
+  
+  &__form {
+    padding: var(--sp-normal);
+    padding-top: 0;
+    display: flex;
+    &-label {
+      padding: var(--sp-normal) var(--sp-normal) var(--sp-ultra-tight);
+    }
+  }
+  
+  &__input-wrapper {
+    display: flex;
+    @extend .cp-fx__item-one;
+    @include dir.side(margin, 0, var(--sp-tight));
+    
+    & .input-container {
+      @extend .cp-fx__item-one;
+    }
+  }
+
+  &__input-status {
+    padding: 0 var(--sp-normal);
+  }
+  &__valid {
+    color: var(--tc-positive-high);
+    padding-bottom: var(--sp-normal);
+  }
+  &__invalid {
+    color: var(--tc-danger-high);
+    padding-bottom: var(--sp-normal);
+  }
+}
\ No newline at end of file
index 0504f474a7463a4c0a8a43e68bd0d9d623c2a60a..8c7323bd80b733e22fe405a5668e278a597843c9 100644 (file)
@@ -16,6 +16,7 @@ import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
 import RoomProfile from '../../molecules/room-profile/RoomProfile';
 import RoomNotification from '../../molecules/room-notification/RoomNotification';
 import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
+import RoomAliases from '../../molecules/room-aliases/RoomAliases';
 
 import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
 import SearchIC from '../../../../public/res/ic/outlined/search.svg';
@@ -71,9 +72,13 @@ function GeneralSettings({ roomId }) {
         <MenuItem variant="danger" onClick={() => roomActions.leave(roomId)} iconSrc={LeaveArrowIC}>Leave</MenuItem>
       </div>
       <div className="room-settings__card">
-        <MenuHeader>Visibility (who can join)</MenuHeader>
+        <MenuHeader>Room visibility (who can join)</MenuHeader>
         <RoomVisibility roomId={roomId} />
       </div>
+      <div className="room-settings__card">
+        <MenuHeader>Room addresses</MenuHeader>
+        <RoomAliases roomId={roomId} />
+      </div>
     </>
   );
 }
index 07e703bfd52311ed247c2b80a7580513acc9c890..e86194e45558801805daba62e5717d2d915e9829 100644 (file)
@@ -34,16 +34,11 @@ function getUsernameOfRoomMember(roomMember) {
 
 async function isRoomAliasAvailable(alias) {
   try {
-    const myUserId = initMatrix.matrixClient.getUserId();
-    const myServer = myUserId.slice(myUserId.indexOf(':') + 1);
     const result = await initMatrix.matrixClient.resolveRoomAlias(alias);
-    const aliasIsRegisteredOnMyServer = typeof result.servers.find((server) => server === myServer) === 'string';
-
-    if (aliasIsRegisteredOnMyServer) return false;
-    return true;
+    if (result.room_id) return false;
+    return false;
   } catch (e) {
     if (e.errcode === 'M_NOT_FOUND') return true;
-    if (e.errcode === 'M_INVALID_PARAM') throw new Error(e);
     return false;
   }
 }