Improve Members Right Panel (#1286)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Wed, 21 Jun 2023 23:14:50 +0000 (09:14 +1000)
committerGitHub <noreply@github.com>
Wed, 21 Jun 2023 23:14:50 +0000 (09:14 +1000)
* fix room members hook

* fix resize observer hook

* add intersection observer hook

* install react-virtual lib

* improve right panel - WIP

* add filters for members

* fix bug in async search

* categories members and add search

* show spinner on room member fetch

* make invite member btn clickable

* so no member text

* add line between room view and member drawer

* fix imports

* add screen size hook

* fix set setting hook

* make member drawer responsive

* extract power level tags hook

* fix room members hook

* fix use async search api

* produce search result on filter change

19 files changed:
package-lock.json
package.json
src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
src/app/components/emoji-board/EmojiBoard.tsx
src/app/hooks/useAsyncSearch.ts
src/app/hooks/useIntersectionObserver.ts [new file with mode: 0644]
src/app/hooks/usePowerLevelTags.ts [new file with mode: 0644]
src/app/hooks/useResizeObserver.ts
src/app/hooks/useRoomMembers.ts
src/app/hooks/useScreenSize.ts [new file with mode: 0644]
src/app/organisms/room/MembersDrawer.css.ts [new file with mode: 0644]
src/app/organisms/room/MembersDrawer.tsx [new file with mode: 0644]
src/app/organisms/room/Room.jsx
src/app/organisms/room/RoomInput.tsx
src/app/organisms/room/RoomViewContent.jsx
src/app/organisms/room/RoomViewHeader.jsx
src/app/state/hooks/settings.ts

index bb98ea6992452169c8fd971fe70f58b304d64ad1..61eebbf90e4a326c10a3e5257b0c61a1a62b150a 100644 (file)
@@ -13,6 +13,7 @@
         "@fontsource/roboto": "4.5.8",
         "@khanacademy/simple-markdown": "0.8.6",
         "@matrix-org/olm": "3.2.14",
+        "@tanstack/react-virtual": "3.0.0-beta.54",
         "@tippyjs/react": "4.2.6",
         "@vanilla-extract/css": "1.9.3",
         "@vanilla-extract/recipes": "0.3.0",
@@ -37,6 +38,7 @@
         "linkify-html": "4.0.2",
         "linkifyjs": "4.0.2",
         "matrix-js-sdk": "24.1.0",
+        "millify": "6.1.0",
         "prop-types": "15.8.1",
         "react": "17.0.2",
         "react-autosize-textarea": "7.1.0",
       "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
       "dev": true
     },
+    "node_modules/@tanstack/react-virtual": {
+      "version": "3.0.0-beta.54",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.54.tgz",
+      "integrity": "sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==",
+      "dependencies": {
+        "@tanstack/virtual-core": "3.0.0-beta.54"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/@tanstack/virtual-core": {
+      "version": "3.0.0-beta.54",
+      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.54.tgz",
+      "integrity": "sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
     "node_modules/@tippyjs/react": {
       "version": "4.2.6",
       "resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz",
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
       "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
       "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
       "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
     },
+    "node_modules/cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
         "node": ">=6.9.0"
       }
     },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
     "node_modules/get-intrinsic": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-glob": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
         "node": ">=8.6"
       }
     },
+    "node_modules/millify": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/millify/-/millify-6.1.0.tgz",
+      "integrity": "sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==",
+      "dependencies": {
+        "yargs": "^17.0.1"
+      },
+      "bin": {
+        "millify": "bin/millify"
+      }
+    },
     "node_modules/mini-svg-data-uri": {
       "version": "1.4.4",
       "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
         "url": "https://github.com/sponsors/mysticatea"
       }
     },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/require-like": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+    },
     "node_modules/string.prototype.matchall": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz",
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "dependencies": {
         "ansi-regex": "^5.0.1"
       },
         "node": ">=0.10.0"
       }
     },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
     "node_modules/wrappy": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
       "dev": true
     },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/yallist": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
         "node": ">= 6"
       }
     },
+    "node_modules/yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dependencies": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/yocto-queue": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
index bf9adeaeaa9d525dee921235aae587df3f95fc38..beaae095615d52f16f3fac9211096ce8aad7f2d1 100644 (file)
@@ -23,6 +23,7 @@
     "@fontsource/roboto": "4.5.8",
     "@khanacademy/simple-markdown": "0.8.6",
     "@matrix-org/olm": "3.2.14",
+    "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tippyjs/react": "4.2.6",
     "@vanilla-extract/css": "1.9.3",
     "@vanilla-extract/recipes": "0.3.0",
@@ -47,6 +48,7 @@
     "linkify-html": "4.0.2",
     "linkifyjs": "4.0.2",
     "matrix-js-sdk": "24.1.0",
+    "millify": "6.1.0",
     "prop-types": "15.8.1",
     "react": "17.0.2",
     "react-autosize-textarea": "7.1.0",
index 17712b8707014dbb689963a08642e499fbb0477a..2e556000caea37ca7dd544bb23932299bb5439e2 100644 (file)
@@ -60,12 +60,13 @@ export function EmoticonAutocomplete({
     );
   }, [imagePacks]);
 
-  const [result, search] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
+  const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
   const autoCompleteEmoticon = result ? result.items : recentEmoji;
 
   useEffect(() => {
-    search(query.text);
-  }, [query.text, search]);
+    if (query.text) search(query.text);
+    else resetSearch();
+  }, [query.text, search, resetSearch]);
 
   const handleAutocomplete: EmoticonCompleteHandler = (key, shortcode) => {
     const emoticonEl = createEmoticonElement(key, shortcode);
index 2edfb8bc43ce5fbc0f417f656d2bc701a1567168..6bea1952bbe06c829e97ee5268917a8a52620bba 100644 (file)
@@ -81,7 +81,7 @@ export function RoomMentionAutocomplete({
     return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
   }, []);
 
-  const [result, search] = useAsyncSearch(
+  const [result, search, resetSearch] = useAsyncSearch(
     allRoomId,
     useCallback(
       (rId) => {
@@ -99,8 +99,9 @@ export function RoomMentionAutocomplete({
   const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
 
   useEffect(() => {
-    search(query.text);
-  }, [query.text, search]);
+    if (query.text) search(query.text);
+    else resetSearch();
+  }, [query.text, search, resetSearch]);
 
   const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
     const mentionEl = createMentionElement(
index 10088ada6dd51e8403c60180263bac14d8ddaa3a..00ecb015872966d4487bd07861e698862aa85847 100644 (file)
@@ -94,12 +94,13 @@ export function UserMentionAutocomplete({
   const roomAliasOrId = room?.getCanonicalAlias() || roomId;
   const members = useRoomMembers(mx, roomId);
 
-  const [result, search] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
+  const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
   const autoCompleteMembers = result ? result.items : members.slice(0, 20);
 
   useEffect(() => {
-    search(query.text);
-  }, [query.text, search]);
+    if (query.text) search(query.text);
+    else resetSearch();
+  }, [query.text, search, resetSearch]);
 
   const handleAutocomplete: MentionAutoCompleteHandler = (uId, name) => {
     const mentionEl = createMentionElement(
index 3b1ccc55462d3c700b73d8db422ef7ba7adc4f55..4005234adb14a39a686a39ab020812e2b7facb0c 100644 (file)
@@ -647,15 +647,20 @@ export function EmojiBoard({
     return list;
   }, [emojiTab, usage, imagePacks]);
 
-  const [result, search] = useAsyncSearch(searchList, getSearchListItemStr, SEARCH_OPTIONS);
+  const [result, search, resetSearch] = useAsyncSearch(
+    searchList,
+    getSearchListItemStr,
+    SEARCH_OPTIONS
+  );
 
   const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
     useCallback(
       (evt) => {
         const term = evt.target.value;
-        search(term);
+        if (term) search(term);
+        else resetSearch();
       },
-      [search]
+      [search, resetSearch]
     ),
     { wait: 200 }
   );
index b083a19ab9abd42ef001c43190674658ae537836..d0e73e7f8a51b98a13a1aee2850b5388af55b48e 100644 (file)
@@ -25,11 +25,13 @@ export type UseAsyncSearchResult<TSearchItem extends object | string | number> =
   items: TSearchItem[];
 };
 
+export type SearchResetHandler = () => void;
+
 export const useAsyncSearch = <TSearchItem extends object | string | number>(
   list: TSearchItem[],
   getItemStr: SearchItemStrGetter<TSearchItem>,
   options?: UseAsyncSearchOptions
-): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler] => {
+): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler, SearchResetHandler] => {
   const [result, setResult] = useState<UseAsyncSearchResult<TSearchItem>>();
 
   const [searchCallback, terminateSearch] = useMemo(() => {
@@ -51,7 +53,7 @@ export const useAsyncSearch = <TSearchItem extends object | string | number>(
     const handleResult: ResultHandler<TSearchItem> = (results, query) =>
       setResult({
         query,
-        items: results,
+        items: [...results],
       });
 
     return AsyncSearch(list, handleMatch, handleResult, options);
@@ -60,15 +62,16 @@ export const useAsyncSearch = <TSearchItem extends object | string | number>(
   const searchHandler: AsyncSearchHandler = useCallback(
     (query) => {
       const normalizedQuery = normalize(query, options?.normalizeOptions);
-      if (!normalizedQuery) {
-        setResult(undefined);
-        return;
-      }
       searchCallback(normalizedQuery);
     },
     [searchCallback, options?.normalizeOptions]
   );
 
+  const resetHandler: SearchResetHandler = useCallback(() => {
+    terminateSearch();
+    setResult(undefined);
+  }, [terminateSearch]);
+
   useEffect(
     () => () => {
       // terminate any ongoing search request on unmount.
@@ -77,5 +80,5 @@ export const useAsyncSearch = <TSearchItem extends object | string | number>(
     [terminateSearch]
   );
 
-  return [result, searchHandler];
+  return [result, searchHandler, resetHandler];
 };
diff --git a/src/app/hooks/useIntersectionObserver.ts b/src/app/hooks/useIntersectionObserver.ts
new file mode 100644 (file)
index 0000000..754007a
--- /dev/null
@@ -0,0 +1,37 @@
+import { useEffect, useState } from 'react';
+
+export type OnIntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
+
+export type IntersectionObserverOpts = {
+  root?: Element | Document | null;
+  rootMargin?: string;
+  threshold?: number | number[];
+};
+
+export const getIntersectionObserverEntry = (
+  target: Element | Document,
+  entries: IntersectionObserverEntry[]
+): IntersectionObserverEntry | undefined => entries.find((entry) => entry.target === target);
+
+export const useIntersectionObserver = (
+  onIntersectionCallback: OnIntersectionCallback,
+  opts?: IntersectionObserverOpts | (() => IntersectionObserverOpts),
+  observeElement?: Element | null | (() => Element | null)
+): IntersectionObserver | undefined => {
+  const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>();
+
+  useEffect(() => {
+    const initOpts = typeof opts === 'function' ? opts() : opts;
+    setIntersectionObserver(new IntersectionObserver(onIntersectionCallback, initOpts));
+  }, [onIntersectionCallback, opts]);
+
+  useEffect(() => {
+    const element = typeof observeElement === 'function' ? observeElement() : observeElement;
+    if (element) intersectionObserver?.observe(element);
+    return () => {
+      if (element) intersectionObserver?.unobserve(element);
+    };
+  }, [intersectionObserver, observeElement]);
+
+  return intersectionObserver;
+};
diff --git a/src/app/hooks/usePowerLevelTags.ts b/src/app/hooks/usePowerLevelTags.ts
new file mode 100644 (file)
index 0000000..dd0a3df
--- /dev/null
@@ -0,0 +1,38 @@
+import { useCallback, useMemo } from 'react';
+
+export type PowerLevelTag = {
+  name: string;
+};
+export const usePowerLevelTags = () => {
+  const powerLevelTags = useMemo(
+    () => ({
+      9000: {
+        name: 'Goku',
+      },
+      101: {
+        name: 'Founder',
+      },
+      100: {
+        name: 'Admin',
+      },
+      50: {
+        name: 'Moderator',
+      },
+      0: {
+        name: 'Default',
+      },
+    }),
+    []
+  );
+
+  return useCallback(
+    (powerLevel: number): PowerLevelTag => {
+      if (powerLevel >= 9000) return powerLevelTags[9000];
+      if (powerLevel >= 101) return powerLevelTags[101];
+      if (powerLevel === 100) return powerLevelTags[100];
+      if (powerLevel >= 50) return powerLevelTags[50];
+      return powerLevelTags[0];
+    },
+    [powerLevelTags]
+  );
+};
index 69ec65d069dda057ca09fe0f1098581971d7c235..1e0fc7263eeecd4c89cb9cd5ca444d4acffddeb7 100644 (file)
@@ -8,17 +8,18 @@ export const getResizeObserverEntry = (
 ): ResizeObserverEntry | undefined => entries.find((entry) => entry.target === target);
 
 export const useResizeObserver = (
-  element: Element | null,
-  onResizeCallback: OnResizeCallback
+  onResizeCallback: OnResizeCallback,
+  observeElement?: Element | null | (() => Element | null)
 ): ResizeObserver => {
   const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
 
   useEffect(() => {
+    const element = typeof observeElement === 'function' ? observeElement() : observeElement;
     if (element) resizeObserver.observe(element);
     return () => {
       if (element) resizeObserver.unobserve(element);
     };
-  }, [resizeObserver, element]);
+  }, [resizeObserver, observeElement]);
 
   return resizeObserver;
 };
index 544d97a0808d4e6c7c576d3da7f67d7ab78fb84f..df369011b4ee8935fc9462785f645e7c4e0d09ac 100644 (file)
@@ -1,23 +1,25 @@
 import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
 import { useEffect, useState } from 'react';
-import { useAlive } from './useAlive';
 
 export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => {
   const [members, setMembers] = useState<RoomMember[]>([]);
-  const alive = useAlive();
 
   useEffect(() => {
     const room = mx.getRoom(roomId);
+    let loadingMembers = true;
+    let disposed = false;
 
     const updateMemberList = (event?: MatrixEvent) => {
-      if (!room || !alive || (event && event.getRoomId() !== roomId)) return;
+      if (!room || disposed || (event && event.getRoomId() !== roomId)) return;
+      if (loadingMembers) return;
       setMembers(room.getMembers());
     };
 
     if (room) {
-      updateMemberList();
+      setMembers(room.getMembers());
       room.loadMembersIfNeeded().then(() => {
-        if (!alive) return;
+        loadingMembers = false;
+        if (disposed) return;
         updateMemberList();
       });
     }
@@ -25,10 +27,11 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
     mx.on(RoomMemberEvent.Membership, updateMemberList);
     mx.on(RoomMemberEvent.PowerLevel, updateMemberList);
     return () => {
+      disposed = true;
       mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
       mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
     };
-  }, [mx, roomId, alive]);
+  }, [mx, roomId]);
 
   return members;
 };
diff --git a/src/app/hooks/useScreenSize.ts b/src/app/hooks/useScreenSize.ts
new file mode 100644 (file)
index 0000000..4afe908
--- /dev/null
@@ -0,0 +1,36 @@
+import { useCallback, useState } from 'react';
+import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
+
+export const TABLET_BREAKPOINT = 1124;
+export const MOBILE_BREAKPOINT = 750;
+
+export enum ScreenSize {
+  Desktop = 'Desktop',
+  Tablet = 'Tablet',
+  Mobile = 'Mobile',
+}
+
+export const getScreenSize = (width: number): ScreenSize => {
+  if (width > TABLET_BREAKPOINT) return ScreenSize.Desktop;
+  if (width > MOBILE_BREAKPOINT) return ScreenSize.Tablet;
+  return ScreenSize.Mobile;
+};
+
+export const useScreenSize = (): [ScreenSize, number] => {
+  const [size, setSize] = useState<[ScreenSize, number]>([
+    getScreenSize(document.body.clientWidth),
+    document.body.clientWidth,
+  ]);
+  useResizeObserver(
+    useCallback((entries) => {
+      const bodyEntry = getResizeObserverEntry(document.body, entries);
+      if (bodyEntry) {
+        const bWidth = bodyEntry.contentRect.width;
+        setSize([getScreenSize(bWidth), bWidth]);
+      }
+    }, []),
+    document.body
+  );
+
+  return size;
+};
diff --git a/src/app/organisms/room/MembersDrawer.css.ts b/src/app/organisms/room/MembersDrawer.css.ts
new file mode 100644 (file)
index 0000000..2718e92
--- /dev/null
@@ -0,0 +1,64 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const MembersDrawer = style({
+  width: toRem(266),
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+});
+
+export const MembersDrawerHeader = style({
+  flexShrink: 0,
+  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+  borderBottomWidth: config.borderWidth.B300,
+});
+
+export const MemberDrawerContentBase = style({
+  position: 'relative',
+  overflow: 'hidden',
+});
+
+export const MemberDrawerContent = style({
+  padding: `${config.space.S300} 0`,
+});
+
+const ScrollBtnAnime = keyframes({
+  '0%': {
+    transform: `translate(-50%, -100%) scale(0)`,
+  },
+  '100%': {
+    transform: `translate(-50%, 0) scale(1)`,
+  },
+});
+
+export const DrawerScrollTop = style({
+  position: 'absolute',
+  top: config.space.S200,
+  left: '50%',
+  transform: 'translateX(-50%)',
+  zIndex: 1,
+  animation: `${ScrollBtnAnime} 100ms`,
+});
+
+export const DrawerGroup = style({
+  padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
+});
+
+export const MembersGroup = style({
+  paddingLeft: config.space.S200,
+});
+export const MembersGroupLabel = style({
+  padding: config.space.S200,
+  selectors: {
+    '&:not(:first-child)': {
+      paddingTop: config.space.S500,
+    },
+  },
+});
+
+export const DrawerVirtualItem = style({
+  position: 'absolute',
+  top: 0,
+  left: 0,
+  width: '100%',
+});
diff --git a/src/app/organisms/room/MembersDrawer.tsx b/src/app/organisms/room/MembersDrawer.tsx
new file mode 100644 (file)
index 0000000..d50c366
--- /dev/null
@@ -0,0 +1,528 @@
+import React, {
+  ChangeEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Avatar,
+  AvatarFallback,
+  AvatarImage,
+  Box,
+  Chip,
+  ContainerColor,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  MenuItem,
+  PopOut,
+  Scroll,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import millify from 'millify';
+import classNames from 'classnames';
+
+import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
+import * as css from './MembersDrawer.css';
+import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import {
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import { Membership } from '../../../types/matrix/room';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
+import { useDebounce } from '../../hooks/useDebounce';
+import colorMXID from '../../../util/colorMXID';
+import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+
+export const MembershipFilters = {
+  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
+  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
+  filterLeaved: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() === m.events.member?.getSender(),
+  filterKicked: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() !== m.events.member?.getSender(),
+  filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
+};
+
+export type MembershipFilterFn = (m: RoomMember) => boolean;
+
+export type MembershipFilter = {
+  name: string;
+  filterFn: MembershipFilterFn;
+  color: ContainerColor;
+};
+
+const useMembershipFilterMenu = (): MembershipFilter[] =>
+  useMemo(
+    () => [
+      {
+        name: 'Joined',
+        filterFn: MembershipFilters.filterJoined,
+        color: 'Surface',
+      },
+      {
+        name: 'Invited',
+        filterFn: MembershipFilters.filterInvited,
+        color: 'Success',
+      },
+      {
+        name: 'Left',
+        filterFn: MembershipFilters.filterLeaved,
+        color: 'Secondary',
+      },
+      {
+        name: 'Kicked',
+        filterFn: MembershipFilters.filterKicked,
+        color: 'Warning',
+      },
+      {
+        name: 'Banned',
+        filterFn: MembershipFilters.filterBanned,
+        color: 'Critical',
+      },
+    ],
+    []
+  );
+
+export const SortFilters = {
+  filterAscending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
+  filterDescending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
+  filterNewestFirst: (a: RoomMember, b: RoomMember) =>
+    (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
+  filterOldest: (a: RoomMember, b: RoomMember) =>
+    (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
+};
+
+export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
+
+export type SortFilter = {
+  name: string;
+  filterFn: SortFilterFn;
+};
+
+const useSortFilterMenu = (): SortFilter[] =>
+  useMemo(
+    () => [
+      {
+        name: 'A to Z',
+        filterFn: SortFilters.filterAscending,
+      },
+      {
+        name: 'Z to A',
+        filterFn: SortFilters.filterDescending,
+      },
+      {
+        name: 'Newest First',
+        filterFn: SortFilters.filterNewestFirst,
+      },
+      {
+        name: 'Oldest First',
+        filterFn: SortFilters.filterOldest,
+      },
+    ],
+    []
+  );
+
+export type MembersFilterOptions = {
+  membershipFilter: MembershipFilter;
+  sortFilter: SortFilter;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  limit: 100,
+  matchOptions: {
+    contain: true,
+  },
+};
+const getMemberItemStr = (m: RoomMember) => [m.name, m.userId];
+
+type MembersDrawerProps = {
+  room: Room;
+};
+export function MembersDrawer({ room }: MembersDrawerProps) {
+  const mx = useMatrixClient();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+  const members = useRoomMembers(mx, room.roomId);
+  const getPowerLevelTag = usePowerLevelTags();
+  const fetchingMembers = members.length < room.getJoinedMemberCount();
+
+  const membershipFilterMenu = useMembershipFilterMenu();
+  const sortFilterMenu = useSortFilterMenu();
+  const [filter, setFilter] = useState<MembersFilterOptions>({
+    membershipFilter: membershipFilterMenu[0],
+    sortFilter: sortFilterMenu[0],
+  });
+  const [onTop, setOnTop] = useState(true);
+
+  const filteredMembers = useMemo(
+    () =>
+      members
+        .filter(filter.membershipFilter.filterFn)
+        .sort(filter.sortFilter.filterFn)
+        .sort((a, b) => b.powerLevel - a.powerLevel),
+    [members, filter]
+  );
+
+  const [result, search, resetSearch] = useAsyncSearch(
+    filteredMembers,
+    getMemberItemStr,
+    SEARCH_OPTIONS
+  );
+  if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
+
+  const processMembers = result ? result.items : filteredMembers;
+
+  const PLTagOrRoomMember = useMemo(() => {
+    let prevTag: PowerLevelTag | undefined;
+    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
+    processMembers.forEach((m) => {
+      const plTag = getPowerLevelTag(m.powerLevel);
+      if (plTag !== prevTag) {
+        prevTag = plTag;
+        tagOrMember.push(plTag);
+      }
+      tagOrMember.push(m);
+    });
+    return tagOrMember;
+  }, [processMembers, getPowerLevelTag]);
+
+  const virtualizer = useVirtualizer({
+    count: PLTagOrRoomMember.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 40,
+    overscan: 10,
+  });
+
+  useIntersectionObserver(
+    useCallback((intersectionEntries) => {
+      if (!scrollTopAnchorRef.current) return;
+      const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries);
+      if (entry) setOnTop(entry.isIntersecting);
+    }, []),
+    useCallback(() => ({ root: scrollRef.current }), []),
+    useCallback(() => scrollTopAnchorRef.current, [])
+  );
+
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
+    useCallback(
+      (evt) => {
+        if (evt.target.value) search(evt.target.value);
+        else resetSearch();
+      },
+      [search, resetSearch]
+    ),
+    { wait: 200 }
+  );
+
+  const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const btn = evt.currentTarget as HTMLButtonElement;
+    const userId = btn.getAttribute('data-user-id');
+    openProfileViewer(userId, room.roomId);
+  };
+
+  return (
+    <Box className={css.MembersDrawer} 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 size="H5" truncate>
+              {`${millify(room.getJoinedMemberCount(), { precision: 1 })} Members`}
+            </Text>
+          </Box>
+          <Box shrink="No" alignItems="Center">
+            <TooltipProvider
+              position="Bottom"
+              align="End"
+              tooltip={
+                <Tooltip>
+                  <Text>Invite Member</Text>
+                </Tooltip>
+              }
+            >
+              {(triggerRef) => (
+                <IconButton
+                  ref={triggerRef}
+                  variant="Background"
+                  onClick={() => openInviteUser(room.roomId)}
+                >
+                  <Icon src={Icons.UserPlus} />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          </Box>
+        </Box>
+      </Header>
+      <Box className={css.MemberDrawerContentBase} grow="Yes">
+        <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover">
+          <Box className={css.MemberDrawerContent} direction="Column" gap="400">
+            <Box className={css.DrawerGroup} direction="Column" gap="100">
+              <Text size="L400">Filter</Text>
+              <Box alignItems="Center" gap="100" wrap="Wrap">
+                <UseStateProvider initial={false}>
+                  {(open, setOpen) => (
+                    <PopOut
+                      open={open}
+                      position="Bottom"
+                      align="Start"
+                      content={
+                        <FocusTrap
+                          focusTrapOptions={{
+                            initialFocus: false,
+                            onDeactivate: () => setOpen(false),
+                            clickOutsideDeactivates: true,
+                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                          }}
+                        >
+                          <Menu style={{ padding: config.space.S100 }}>
+                            {membershipFilterMenu.map((menuItem) => (
+                              <MenuItem
+                                key={menuItem.name}
+                                variant={
+                                  menuItem.name === filter.membershipFilter.name
+                                    ? menuItem.color
+                                    : 'Surface'
+                                }
+                                radii="300"
+                                onClick={() => {
+                                  setFilter((f) => ({ ...f, membershipFilter: menuItem }));
+                                  setOpen(false);
+                                }}
+                              >
+                                <Text>{menuItem.name}</Text>
+                              </MenuItem>
+                            ))}
+                          </Menu>
+                        </FocusTrap>
+                      }
+                    >
+                      {(anchorRef) => (
+                        <Chip
+                          ref={anchorRef}
+                          onClick={() => setOpen(!open)}
+                          variant={filter.membershipFilter.color}
+                          radii="400"
+                          outlined
+                          after={<Icon src={Icons.ChevronBottom} size="50" />}
+                        >
+                          <Text size="T200">{filter.membershipFilter.name}</Text>
+                        </Chip>
+                      )}
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+                <UseStateProvider initial={false}>
+                  {(open, setOpen) => (
+                    <PopOut
+                      open={open}
+                      position="Bottom"
+                      align="Start"
+                      content={
+                        <FocusTrap
+                          focusTrapOptions={{
+                            initialFocus: false,
+                            onDeactivate: () => setOpen(false),
+                            clickOutsideDeactivates: true,
+                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                          }}
+                        >
+                          <Menu style={{ padding: config.space.S100 }}>
+                            {sortFilterMenu.map((menuItem) => (
+                              <MenuItem
+                                key={menuItem.name}
+                                variant="Surface"
+                                aria-pressed={menuItem.name === filter.sortFilter.name}
+                                radii="300"
+                                onClick={() => {
+                                  setFilter((f) => ({ ...f, sortFilter: menuItem }));
+                                  setOpen(false);
+                                }}
+                              >
+                                <Text>{menuItem.name}</Text>
+                              </MenuItem>
+                            ))}
+                          </Menu>
+                        </FocusTrap>
+                      }
+                    >
+                      {(anchorRef) => (
+                        <Chip
+                          ref={anchorRef}
+                          onClick={() => setOpen(!open)}
+                          variant="Surface"
+                          radii="400"
+                          outlined
+                          after={<Icon src={Icons.ChevronBottom} size="50" />}
+                        >
+                          <Text size="T200">{`Order: ${filter.sortFilter.name}`}</Text>
+                        </Chip>
+                      )}
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+              </Box>
+            </Box>
+
+            <Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="100">
+              <Text size="L400">Search</Text>
+              <Input
+                ref={searchInputRef}
+                onChange={handleSearchChange}
+                style={{ paddingRight: config.space.S200 }}
+                placeholder="Type name..."
+                variant="Surface"
+                size="400"
+                outlined
+                radii="400"
+                before={<Icon size="50" src={Icons.Search} />}
+                after={
+                  result && (
+                    <Chip
+                      variant={result.items.length > 0 ? 'Success' : 'Critical'}
+                      size="400"
+                      radii="Pill"
+                      onClick={() => {
+                        if (searchInputRef.current) searchInputRef.current.value = '';
+                        resetSearch();
+                      }}
+                      after={<Icon size="50" src={Icons.Cross} />}
+                    >
+                      <Text size="B300">{`${result.items.length || 'No'} ${
+                        result.items.length === 1 ? 'Result' : 'Results'
+                      }`}</Text>
+                    </Chip>
+                  )
+                }
+              />
+            </Box>
+
+            {!onTop && (
+              <Box className={css.DrawerScrollTop}>
+                <IconButton
+                  onClick={() => virtualizer.scrollToOffset(0)}
+                  variant="Surface"
+                  radii="Pill"
+                  outlined
+                  size="300"
+                  aria-label="Scroll to Top"
+                >
+                  <Icon src={Icons.ChevronTop} size="300" />
+                </IconButton>
+              </Box>
+            )}
+
+            {!fetchingMembers && !result && processMembers.length === 0 && (
+              <Text style={{ padding: config.space.S300 }} align="Center">
+                {`No "${filter.membershipFilter.name}" Members`}
+              </Text>
+            )}
+
+            <Box className={css.MembersGroup} direction="Column" gap="100">
+              <div
+                style={{
+                  position: 'relative',
+                  height: virtualizer.getTotalSize(),
+                }}
+              >
+                {virtualizer.getVirtualItems().map((vItem) => {
+                  const tagOrMember = PLTagOrRoomMember[vItem.index];
+                  if (!('userId' in tagOrMember)) {
+                    return (
+                      <Text
+                        style={{
+                          transform: `translateY(${vItem.start}px)`,
+                        }}
+                        data-index={vItem.index}
+                        ref={virtualizer.measureElement}
+                        key={`${room.roomId}-${vItem.index}`}
+                        className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
+                        size="O400"
+                      >
+                        {tagOrMember.name}
+                      </Text>
+                    );
+                  }
+
+                  const member = tagOrMember;
+                  const avatarUrl = member.getAvatarUrl(
+                    mx.baseUrl,
+                    100,
+                    100,
+                    'crop',
+                    undefined,
+                    false
+                  );
+
+                  return (
+                    <MenuItem
+                      style={{
+                        padding: config.space.S200,
+                        transform: `translateY(${vItem.start}px)`,
+                      }}
+                      data-index={vItem.index}
+                      data-user-id={member.userId}
+                      ref={virtualizer.measureElement}
+                      key={`${room.roomId}-${member.userId}`}
+                      className={css.DrawerVirtualItem}
+                      variant="Background"
+                      radii="400"
+                      onClick={handleMemberClick}
+                      before={
+                        <Avatar size="200">
+                          {avatarUrl ? (
+                            <AvatarImage src={avatarUrl} />
+                          ) : (
+                            <AvatarFallback
+                              style={{
+                                background: colorMXID(member.userId),
+                                color: 'white',
+                              }}
+                            >
+                              <Text size="T200">{member.name[0]}</Text>
+                            </AvatarFallback>
+                          )}
+                        </Avatar>
+                      }
+                    >
+                      <Text size="T400" truncate>
+                        {member.name}
+                      </Text>
+                    </MenuItem>
+                  );
+                })}
+              </div>
+            </Box>
+
+            {fetchingMembers && (
+              <Box justifyContent="Center">
+                <Spinner />
+              </Box>
+            )}
+          </Box>
+        </Scroll>
+      </Box>
+    </Box>
+  );
+}
index 9d861c96296ea677eb05b91ed747716164107489..0603b985a494d31d110e324a776bec19432a5f1b 100644 (file)
@@ -1,9 +1,9 @@
 import React, { useState, useEffect } from 'react';
 import './Room.scss';
+import { Line } from 'folds';
 
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
-import settings from '../../../client/state/settings';
 import RoomTimeline from '../../../client/state/RoomTimeline';
 import navigation from '../../../client/state/navigation';
 import { openNavigation } from '../../../client/action/navigation';
@@ -11,7 +11,10 @@ import { openNavigation } from '../../../client/action/navigation';
 import Welcome from '../welcome/Welcome';
 import RoomView from './RoomView';
 import RoomSettings from './RoomSettings';
-import PeopleDrawer from './PeopleDrawer';
+import { MembersDrawer } from './MembersDrawer';
+import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
 
 function Room() {
   const [roomInfo, setRoomInfo] = useState({
@@ -19,7 +22,8 @@ function Room() {
     roomTimeline: null,
     eventId: null,
   });
-  const [isDrawer, setIsDrawer] = useState(settings.isPeopleDrawer);
+  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+  const [screenSize] = useScreenSize();
 
   const mx = initMatrix.matrixClient;
 
@@ -49,14 +53,6 @@ function Room() {
     };
   }, [roomInfo, mx]);
 
-  useEffect(() => {
-    const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
-    settings.on(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-    return () => {
-      settings.removeListener(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-    };
-  }, []);
-
   const { room, roomTimeline, eventId } = roomInfo;
   if (roomTimeline === null) {
     setTimeout(() => openNavigation());
@@ -69,7 +65,13 @@ function Room() {
         <RoomSettings roomId={roomTimeline.roomId} />
         <RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
       </div>
-      {isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
+
+      {screenSize === ScreenSize.Desktop && isDrawer && (
+        <>
+          <Line variant="Background" direction="Vertical" size="300" />
+          <MembersDrawer room={room} />
+        </>
+      )}
     </div>
   );
 }
index 39016add2fc3b42b636682dc316c44b3c78cb6b0..e869e16d0a8eefcb7956fa38f016bbb647190bb8 100644 (file)
@@ -97,7 +97,7 @@ import { MessageReply } from '../../molecules/message/Message';
 import colorMXID from '../../../util/colorMXID';
 import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
 import { sanitizeText } from '../../utils/sanitize';
-import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
+import { useScreenSize } from '../../hooks/useScreenSize';
 
 interface RoomInputProps {
   roomViewRef: RefObject<HTMLElement>;
@@ -161,15 +161,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     const handlePaste = useFilePasteHandler(handleFiles);
     const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles);
 
-    const [mobile, setMobile] = useState(document.body.clientWidth < 500);
-    useResizeObserver(
-      document.body,
-      useCallback((entries) => {
-        const bodyEntry = getResizeObserverEntry(document.body, entries);
-        if (bodyEntry && bodyEntry.contentRect.width < 500) setMobile(true);
-        else setMobile(false);
-      }, [])
-    );
+    const [, screenWidth] = useScreenSize();
+    const hideStickerBtn = screenWidth < 500;
 
     useEffect(() => {
       Transforms.insertFragment(editor, msgDraft);
@@ -515,7 +508,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                   >
                     {(anchorRef) => (
                       <>
-                        {!mobile && (
+                        {!hideStickerBtn && (
                           <IconButton
                             aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
                             onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
@@ -532,7 +525,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                         <IconButton
                           ref={anchorRef}
                           aria-pressed={
-                            mobile ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+                            hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
                           }
                           onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
                           variant="SurfaceVariant"
@@ -542,7 +535,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                           <Icon
                             src={Icons.Smile}
                             filled={
-                              mobile ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+                              hideStickerBtn
+                                ? !!emojiBoardTab
+                                : emojiBoardTab === EmojiBoardTab.Emoji
                             }
                           />
                         </IconButton>
index fe598bf62b2e0fe3451ba81e25da18c1ca26d02a..5726fe119151bb01f004b0a0bcfd9487bb6bd6d4 100644 (file)
@@ -486,7 +486,6 @@ function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
   }, [newEvent]);
 
   useResizeObserver(
-    roomInputRef.current,
     useCallback((entries) => {
       if (!roomInputRef.current) return;
       const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
@@ -497,7 +496,8 @@ function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
       if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
         timelineScroll.scrollToBottom();
       }
-    }, [roomInputRef])
+    }, [roomInputRef]),
+    useCallback(() => roomInputRef.current, [roomInputRef]),
   );
   
   const listenKeyboard = useCallback((event) => {
index 46a6ba0e318c8ad6cbdc528c987bad7a2ccec5e0..6571241ebe57d77b108ceabaf8c3ca134cad2c86 100644 (file)
@@ -8,8 +8,11 @@ import { blurOnBubbling } from '../../atoms/button/script';
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
-import { toggleRoomSettings, openReusableContextMenu, openNavigation } from '../../../client/action/navigation';
-import { togglePeopleDrawer } from '../../../client/action/settings';
+import {
+  toggleRoomSettings,
+  openReusableContextMenu,
+  openNavigation,
+} from '../../../client/action/navigation';
 import colorMXID from '../../../util/colorMXID';
 import { getEventCords } from '../../../util/common';
 
@@ -28,23 +31,26 @@ import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg
 import BackArrowIC from '../../../../public/res/ic/outlined/chevron-left.svg';
 
 import { useForceUpdate } from '../../hooks/useForceUpdate';
+import { useSetSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
 
 function RoomViewHeader({ roomId }) {
   const [, forceUpdate] = useForceUpdate();
   const mx = initMatrix.matrixClient;
   const isDM = initMatrix.roomList.directs.has(roomId);
   const room = mx.getRoom(roomId);
+  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
   let avatarSrc = room.getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
-  avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
+  avatarSrc = isDM
+    ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop')
+    : avatarSrc;
   const roomName = room.name;
 
   const roomHeaderBtnRef = useRef(null);
   useEffect(() => {
     const settingsToggle = (isVisibile) => {
       const rawIcon = roomHeaderBtnRef.current.lastElementChild;
-      rawIcon.style.transform = isVisibile
-        ? 'rotateX(180deg)'
-        : 'rotateX(0deg)';
+      rawIcon.style.transform = isVisibile ? 'rotateX(180deg)' : 'rotateX(0deg)';
     };
     navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
     return () => {
@@ -66,11 +72,9 @@ function RoomViewHeader({ roomId }) {
   }, [roomId]);
 
   const openRoomOptions = (e) => {
-    openReusableContextMenu(
-      'bottom',
-      getEventCords(e, '.ic-btn'),
-      (closeMenu) => <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />,
-    );
+    openReusableContextMenu('bottom', getEventCords(e, '.ic-btn'), (closeMenu) => (
+      <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />
+    ));
   };
 
   return (
@@ -90,18 +94,34 @@ function RoomViewHeader({ roomId }) {
       >
         <Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" />
         <TitleWrapper>
-          <Text variant="h2" weight="medium" primary>{twemojify(roomName)}</Text>
+          <Text variant="h2" weight="medium" primary>
+            {twemojify(roomName)}
+          </Text>
         </TitleWrapper>
         <RawIcon src={ChevronBottomIC} />
       </button>
-      {mx.isRoomEncrypted(roomId) === false && <IconButton onClick={() => toggleRoomSettings(tabText.SEARCH)} tooltip="Search" src={SearchIC} />}
-      <IconButton className="room-header__drawer-btn" onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
-      <IconButton className="room-header__members-btn" onClick={() => toggleRoomSettings(tabText.MEMBERS)} tooltip="Members" src={UserIC} />
+      {mx.isRoomEncrypted(roomId) === false && (
+        <IconButton
+          onClick={() => toggleRoomSettings(tabText.SEARCH)}
+          tooltip="Search"
+          src={SearchIC}
+        />
+      )}
       <IconButton
-        onClick={openRoomOptions}
-        tooltip="Options"
-        src={VerticalMenuIC}
+        className="room-header__drawer-btn"
+        onClick={() => {
+          setPeopleDrawer((t) => !t);
+        }}
+        tooltip="People"
+        src={UserIC}
       />
+      <IconButton
+        className="room-header__members-btn"
+        onClick={() => toggleRoomSettings(tabText.MEMBERS)}
+        tooltip="Members"
+        src={UserIC}
+      />
+      <IconButton onClick={openRoomOptions} tooltip="Options" src={VerticalMenuIC} />
     </Header>
   );
 }
index 3f4dab60d0267b72568ef0b463ad1c6fbbd1776e..43b565534a95d7ce1ba750f7bbf9ecebbfef8267 100644 (file)
@@ -10,9 +10,9 @@ export const useSetSetting = <K extends keyof Settings>(
 ) => {
   const setterAtom = useMemo(
     () =>
-      atom<null, Settings[K]>(null, (get, set, value) => {
+      atom<null, Settings[K] | ((s: Settings[K]) => Settings[K])>(null, (get, set, value) => {
         const s = { ...get(settingsAtom) };
-        s[key] = value;
+        s[key] = typeof value === 'function' ? value(s[key]) : value;
         set(settingsAtom, s);
       }),
     [settingsAtom, key]
@@ -24,11 +24,10 @@ export const useSetSetting = <K extends keyof Settings>(
 export const useSetting = <K extends keyof Settings>(
   settingsAtom: WritableAtom<Settings, Settings>,
   key: K
-): [Settings[K], SetAtom<Settings[K], void>] => {
+): [Settings[K], SetAtom<Settings[K] | ((s: Settings[K]) => Settings[K]), void>] => {
   const selector = useMemo(() => (s: Settings) => s[key], [key]);
   const setting = useAtomValue(selectAtom(settingsAtom, selector));
 
   const setter = useSetSetting(settingsAtom, key);
-
   return [setting, setter];
 };