Add search modal (#132)
authorAjay Bura <ajbura@gmail.com>
Fri, 10 Dec 2021 11:52:53 +0000 (17:22 +0530)
committerAjay Bura <ajbura@gmail.com>
Fri, 10 Dec 2021 11:52:53 +0000 (17:22 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
src/app/atoms/button/IconButton.jsx
src/app/molecules/room-selector/RoomSelector.jsx
src/app/organisms/navigation/SideBar.jsx
src/app/organisms/pw/Dialogs.jsx
src/app/organisms/search/Search.jsx [new file with mode: 0644]
src/app/organisms/search/Search.scss [new file with mode: 0644]
src/client/action/navigation.js
src/client/state/cons.js
src/client/state/navigation.js
src/util/AsyncSearch.js

index c81c3b10f048663c342f0a19dc15854da8e9a00f..67b8a653cc296461576a85084d84c93f0e0ae13e 100644 (file)
@@ -9,7 +9,7 @@ import Text from '../text/Text';
 
 const IconButton = React.forwardRef(({
   variant, size, type,
-  tooltip, tooltipPlacement, src, onClick,
+  tooltip, tooltipPlacement, src, onClick, tabIndex,
 }, ref) => {
   const btn = (
     <button
@@ -19,6 +19,7 @@ const IconButton = React.forwardRef(({
       onClick={onClick}
       // eslint-disable-next-line react/button-has-type
       type={type}
+      tabIndex={tabIndex}
     >
       <RawIcon size={size} src={src} />
     </button>
@@ -41,6 +42,7 @@ IconButton.defaultProps = {
   tooltip: null,
   tooltipPlacement: 'top',
   onClick: null,
+  tabIndex: 0,
 };
 
 IconButton.propTypes = {
@@ -51,6 +53,7 @@ IconButton.propTypes = {
   tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
   src: PropTypes.string.isRequired,
   onClick: PropTypes.func,
+  tabIndex: PropTypes.number,
 };
 
 export default IconButton;
index d5bb1c30ebd4067a166d89130e3cec7dc2fc5267..4966d832e3247b40259d2e8d297d957841dd3884 100644 (file)
@@ -41,7 +41,7 @@ RoomSelectorWrapper.propTypes = {
 };
 
 function RoomSelector({
-  name, roomId, imageSrc, iconSrc,
+  name, parentName, roomId, imageSrc, iconSrc,
   isSelected, isUnread, notificationCount, isAlert,
   options, onClick,
 }) {
@@ -58,7 +58,15 @@ function RoomSelector({
             iconSrc={iconSrc}
             size="extra-small"
           />
-          <Text variant="b1">{twemojify(name)}</Text>
+          <Text variant="b1">
+            {twemojify(name)}
+            {parentName && (
+              <span className="text text-b3">
+                {' — '}
+                {twemojify(parentName)}
+              </span>
+            )}
+          </Text>
           { isUnread && (
             <NotificationBadge
               alert={isAlert}
@@ -73,6 +81,7 @@ function RoomSelector({
   );
 }
 RoomSelector.defaultProps = {
+  parentName: null,
   isSelected: false,
   imageSrc: null,
   iconSrc: null,
@@ -80,6 +89,7 @@ RoomSelector.defaultProps = {
 };
 RoomSelector.propTypes = {
   name: PropTypes.string.isRequired,
+  parentName: PropTypes.string,
   roomId: PropTypes.string.isRequired,
   imageSrc: PropTypes.string,
   iconSrc: PropTypes.string,
index 3be5e195788b4791fa2aff48a56a216466076bbb..40328d9248b5afd07a96943c875d1aca1a82f36c 100644 (file)
@@ -6,7 +6,7 @@ import cons from '../../../client/state/cons';
 import colorMXID from '../../../util/colorMXID';
 import logout from '../../../client/action/logout';
 import {
-  selectTab, openInviteList, openPublicRooms, openSettings,
+  selectTab, openInviteList, openSearch, openSettings,
 } from '../../../client/action/navigation';
 import navigation from '../../../client/state/navigation';
 import { abbreviateNumber } from '../../../util/common';
@@ -17,7 +17,7 @@ import ContextMenu, { MenuItem, MenuHeader, MenuBorder } from '../../atoms/conte
 
 import HomeIC from '../../../../public/res/ic/outlined/home.svg';
 import UserIC from '../../../../public/res/ic/outlined/user.svg';
-import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
+import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
 import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
 import PowerIC from '../../../../public/res/ic/outlined/power.svg';
@@ -205,6 +205,11 @@ function SideBar() {
       <div className="sidebar__sticky">
         <div className="sidebar-divider" />
         <div className="sticky-container">
+          <SidebarAvatar
+            onClick={() => openSearch()}
+            tooltip="Search"
+            iconSrc={SearchIC}
+          />
           { totalInvites !== 0 && (
             <SidebarAvatar
               isUnread
index 3448ebe95a37674f0df3fca2644bcd09a6ccc00f..0fa89cb432f0fbc12dbde8f8079f63ee50b52d39 100644 (file)
@@ -2,12 +2,14 @@ import React from 'react';
 
 import ReadReceipts from '../read-receipts/ReadReceipts';
 import ProfileViewer from '../profile-viewer/ProfileViewer';
+import Search from '../search/Search';
 
 function Dialogs() {
   return (
     <>
       <ReadReceipts />
       <ProfileViewer />
+      <Search />
     </>
   );
 }
diff --git a/src/app/organisms/search/Search.jsx b/src/app/organisms/search/Search.jsx
new file mode 100644 (file)
index 0000000..80ddce8
--- /dev/null
@@ -0,0 +1,220 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './Search.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+import AsyncSearch from '../../../util/AsyncSearch';
+import { selectRoom, selectTab } from '../../../client/action/navigation';
+
+import Text from '../../atoms/text/Text';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+import IconButton from '../../atoms/button/IconButton';
+import Input from '../../atoms/input/Input';
+import RawModal from '../../atoms/modal/RawModal';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import Divider from '../../atoms/divider/Divider';
+import RoomSelector from '../../molecules/room-selector/RoomSelector';
+
+import SearchIC from '../../../../public/res/ic/outlined/search.svg';
+import HashIC from '../../../../public/res/ic/outlined/hash.svg';
+import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
+import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
+import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+function useVisiblityToggle(setResult) {
+  const [isOpen, setIsOpen] = useState(false);
+
+  useEffect(() => {
+    const handleSearchOpen = (term) => {
+      setResult({
+        term,
+        chunk: [],
+      });
+      setIsOpen(true);
+    };
+    navigation.on(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
+    return () => {
+      navigation.removeListener(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (isOpen === false) {
+      setResult(undefined);
+    }
+  }, [isOpen]);
+
+  const requestClose = () => setIsOpen(false);
+
+  return [isOpen, requestClose];
+}
+
+function Search() {
+  const [result, setResult] = useState(null);
+  const [asyncSearch] = useState(new AsyncSearch());
+  const [isOpen, requestClose] = useVisiblityToggle(setResult);
+  const searchRef = useRef(null);
+  const mx = initMatrix.matrixClient;
+
+  const handleSearchResults = (chunk, term) => {
+    setResult({
+      term,
+      chunk,
+    });
+  };
+
+  const generateResults = (term) => {
+    const prefix = term.match(/^[#@*]/)?.[0];
+    const { roomIdToParents } = initMatrix.roomList;
+
+    const mapRoomIds = (roomIds, type) => roomIds.map((roomId) => {
+      const room = mx.getRoom(roomId);
+      const parentSet = roomIdToParents.get(roomId);
+      const parentNames = parentSet
+        ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
+        : undefined;
+
+      const parents = parentNames ? parentNames.join(', ') : null;
+
+      return ({
+        type,
+        name: room.name,
+        parents,
+        roomId,
+        room,
+      });
+    });
+
+    if (term.length === 1) {
+      const { roomList } = initMatrix;
+      const spaces = mapRoomIds([...roomList.spaces], 'space').reverse();
+      const rooms = mapRoomIds([...roomList.rooms], 'room').reverse();
+      const directs = mapRoomIds([...roomList.directs], 'direct').reverse();
+
+      if (prefix === '*') {
+        asyncSearch.setup(spaces, { keys: 'name', isContain: true, limit: 20 });
+        handleSearchResults(spaces, '*');
+      } else if (prefix === '#') {
+        asyncSearch.setup(rooms, { keys: 'name', isContain: true, limit: 20 });
+        handleSearchResults(rooms, '#');
+      } else if (prefix === '@') {
+        asyncSearch.setup(directs, { keys: 'name', isContain: true, limit: 20 });
+        handleSearchResults(directs, '@');
+      } else {
+        const dataList = spaces.concat(rooms, directs);
+        asyncSearch.setup(dataList, { keys: 'name', isContain: true, limit: 20 });
+        asyncSearch.search(term);
+      }
+    } else {
+      asyncSearch.search(prefix ? term.slice(1) : term);
+    }
+  };
+
+  const handleAfterOpen = () => {
+    searchRef.current.focus();
+    asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchResults);
+
+    if (typeof result.term === 'string') {
+      generateResults(result.term);
+      searchRef.current.value = result.term;
+    }
+  };
+
+  const handleAfterClose = () => {
+    asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchResults);
+  };
+
+  const handleOnChange = () => {
+    const { value } = searchRef.current;
+    generateResults(value);
+  };
+
+  const handleCross = (e) => {
+    e.preventDefault();
+    const { value } = searchRef.current;
+    if (value.length === 0) requestClose();
+    else {
+      searchRef.current.value = '';
+      searchRef.current.focus();
+    }
+  };
+
+  const openItem = (roomId, type) => {
+    if (type === 'space') selectTab(roomId);
+    else selectRoom(roomId);
+    requestClose();
+  };
+
+  const openFirstResult = () => {
+    const { chunk } = result;
+    if (chunk?.length > 0) {
+      const item = chunk[0];
+      openItem(item.roomId, item.type);
+    }
+  };
+
+  const notifs = initMatrix.notifications;
+  const renderRoomSelector = (item) => {
+    const isPrivate = item.room.getJoinRule() === 'invite';
+    let imageSrc = null;
+    let iconSrc = null;
+    if (item.type === 'room') iconSrc = isPrivate ? HashLockIC : HashIC;
+    if (item.type === 'space') iconSrc = isPrivate ? SpaceLockIC : SpaceIC;
+    if (item.type === 'direct') imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+
+    const isUnread = notifs.hasNoti(item.roomId);
+    const noti = notifs.getNoti(item.roomId);
+
+    return (
+      <RoomSelector
+        key={item.roomId}
+        name={item.name}
+        parentName={item.parents}
+        roomId={item.roomId}
+        imageSrc={imageSrc}
+        iconSrc={iconSrc}
+        isUnread={isUnread}
+        notificationCount={noti.total}
+        isAlert={noti.total > 0}
+        onClick={() => openItem(item.roomId, item.type)}
+      />
+    );
+  };
+
+  return (
+    <RawModal
+      className="search-dialog__model dialog-model"
+      isOpen={isOpen}
+      onAfterOpen={handleAfterOpen}
+      onAfterClose={handleAfterClose}
+      onRequestClose={requestClose}
+      size="small"
+    >
+      <div className="search-dialog">
+        <form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult()}}>
+          <RawIcon src={SearchIC} size="small" />
+          <Input
+            onChange={handleOnChange}
+            forwardRef={searchRef}
+            placeholder="Search"
+          />
+          <IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
+        </form>
+        <div className="search-dialog__content-wrapper">
+          <ScrollView autoHide>
+            <div className="search-dialog__content">
+              { Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) }
+            </div>
+          </ScrollView>
+        </div>
+        <div className="search-dialog__footer">
+          <Text variant="b3">Type # for rooms, @ for DMs and * for spaces. Hotkey: Ctrl + k</Text>
+        </div>
+      </div>
+    </RawModal>
+  );
+}
+
+export default Search;
diff --git a/src/app/organisms/search/Search.scss b/src/app/organisms/search/Search.scss
new file mode 100644 (file)
index 0000000..61da28b
--- /dev/null
@@ -0,0 +1,85 @@
+.search-dialog__model {
+  --modal-height: 380px;
+  height: 100%;
+  background-color: var(--bg-surface);
+}
+
+.search-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  &__input {
+    padding: var(--sp-normal);
+    display: flex;
+    align-items: center;
+    position: relative;
+
+    & > .ic-raw {
+      position: absolute;
+      left: calc(var(--sp-normal) + var(--sp-tight));
+      [dir=rtl] & {
+        left: unset;
+        right: calc(var(--sp-normal) + var(--sp-tight));
+      }
+    }
+    & > .ic-btn {
+      border-radius: calc(var(--bo-radius) / 2);
+      position: absolute;
+      right: calc(var(--sp-normal) + var(--sp-extra-tight));
+      [dir=rtl] & {
+        right: unset;
+        left: calc(var(--sp-normal) + var(--sp-extra-tight));
+      }
+    }
+    & .input-container {
+      min-width: 0;
+      flex: 1;
+    }
+
+    & input {
+      padding-left: 40px;
+      padding-right: 40px;
+      font-size: var(--fs-s1);
+      letter-spacing: var(--ls-s1);
+      line-height: var(--lh-s1);
+      color: var(--tc-surface-high);
+    }
+  }
+  &__content-wrapper {
+    min-height: 0;
+    flex: 1;
+    position: relative;
+    &::before,
+    &::after {
+      position: absolute;
+      top: 0;
+      content: "";
+      display: inline-block;
+      width: 100%;
+      height: 8px;
+      background-image: linear-gradient(to bottom, var(--bg-surface), var(--bg-surface-transparent));
+    }
+    &::after {
+      top: unset;
+      bottom: 0;
+      background-image: linear-gradient(to bottom, var(--bg-surface-transparent), var(--bg-surface));
+    }
+  }
+
+  &__content {
+    padding: var(--sp-extra-tight) var(--sp-normal);
+    padding-right: var(--sp-extra-tight);
+    
+    [dir=rtl] & {
+      padding-left: var(--sp-extra-tight);
+      padding-right: var(--sp-normal);
+    }
+  }
+
+  &__footer {
+    padding: var(--sp-tight) var(--sp-normal);
+    text-align: center;
+  }
+
+}
\ No newline at end of file
index ec8504452b994ee9583cb20417dec0f6b4d2c95f..a14e771db37c7c0787351cc40d32ce19c84ebf89 100644 (file)
@@ -97,6 +97,13 @@ function replyTo(userId, eventId, body) {
   });
 }
 
+function openSearch(term) {
+  appDispatcher.dispatch({
+    type: cons.actions.navigation.OPEN_SEARCH,
+    term,
+  });
+}
+
 export {
   selectTab,
   selectSpace,
@@ -111,4 +118,5 @@ export {
   openReadReceipts,
   openRoomOptions,
   replyTo,
+  openSearch,
 };
index 869a4765f63803e4a373d2443958a13f7f371101..df8b5fead2647c545aef3d2ee5b13d4ef081bc92 100644 (file)
@@ -41,6 +41,7 @@ const cons = {
       OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
       OPEN_ROOMOPTIONS: 'OPEN_ROOMOPTIONS',
       CLICK_REPLY_TO: 'CLICK_REPLY_TO',
+      OPEN_SEARCH: 'OPEN_SEARCH',
     },
     room: {
       JOIN: 'JOIN',
@@ -73,6 +74,7 @@ const cons = {
       READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
       ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED',
       REPLY_TO_CLICKED: 'REPLY_TO_CLICKED',
+      SEARCH_OPENED: 'SEARCH_OPENED',
     },
     roomList: {
       ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
index 4f69fd6adc68a31b5dab83f596b8caedb3f064a7..ea84dcad17c43489f29b3b045b9067e3821ca693 100644 (file)
@@ -103,6 +103,12 @@ class Navigation extends EventEmitter {
           action.body,
         );
       },
+      [cons.actions.navigation.OPEN_SEARCH]: () => {
+        this.emit(
+          cons.events.navigation.SEARCH_OPENED,
+          action.term,
+        );
+      },
     };
     actions[action.type]?.();
   }
index b90ae15de8a93e02f3a57c7357974da213394360..82123c78a8a11b337ee3a36516ae993adc741403 100644 (file)
@@ -56,7 +56,7 @@ class AsyncSearch extends EventEmitter {
     this._softReset();
 
     this.term = (this.isCaseSensitive) ? term : term.toLocaleLowerCase();
-    if (this.ignoreWhitespace) this.term = this.term.replace(' ', '');
+    if (this.ignoreWhitespace) this.term = this.term.replaceAll(' ', '');
     if (this.term === '') {
       this._sendFindings();
       return;
@@ -114,7 +114,7 @@ class AsyncSearch extends EventEmitter {
   _compare(item) {
     if (typeof item !== 'string') return false;
     let myItem = (this.isCaseSensitive) ? item : item.toLocaleLowerCase();
-    if (this.ignoreWhitespace) myItem = myItem.replace(' ', '');
+    if (this.ignoreWhitespace) myItem = myItem.replaceAll(' ', '');
 
     if (this.isContain) return myItem.indexOf(this.term) !== -1;
     return myItem.startsWith(this.term);