From c07905c360c7c975d9eda39964764ff0cb63ad01 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 22 Jun 2023 09:14:50 +1000 Subject: [PATCH] Improve Members Right Panel (#1286) * 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 --- package-lock.json | 173 +++++- package.json | 2 + .../autocomplete/EmoticonAutocomplete.tsx | 7 +- .../autocomplete/RoomMentionAutocomplete.tsx | 7 +- .../autocomplete/UserMentionAutocomplete.tsx | 7 +- src/app/components/emoji-board/EmojiBoard.tsx | 11 +- src/app/hooks/useAsyncSearch.ts | 17 +- src/app/hooks/useIntersectionObserver.ts | 37 ++ src/app/hooks/usePowerLevelTags.ts | 38 ++ src/app/hooks/useResizeObserver.ts | 7 +- src/app/hooks/useRoomMembers.ts | 15 +- src/app/hooks/useScreenSize.ts | 36 ++ src/app/organisms/room/MembersDrawer.css.ts | 64 +++ src/app/organisms/room/MembersDrawer.tsx | 528 ++++++++++++++++++ src/app/organisms/room/Room.jsx | 26 +- src/app/organisms/room/RoomInput.tsx | 21 +- src/app/organisms/room/RoomViewContent.jsx | 4 +- src/app/organisms/room/RoomViewHeader.jsx | 56 +- src/app/state/hooks/settings.ts | 7 +- 19 files changed, 984 insertions(+), 79 deletions(-) create mode 100644 src/app/hooks/useIntersectionObserver.ts create mode 100644 src/app/hooks/usePowerLevelTags.ts create mode 100644 src/app/hooks/useScreenSize.ts create mode 100644 src/app/organisms/room/MembersDrawer.css.ts create mode 100644 src/app/organisms/room/MembersDrawer.tsx diff --git a/package-lock.json b/package-lock.json index bb98ea6..61eebbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -1106,6 +1108,30 @@ "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", @@ -1669,7 +1695,6 @@ "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" } @@ -2058,6 +2083,19 @@ "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", @@ -3349,6 +3387,14 @@ "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", @@ -3771,6 +3817,14 @@ "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", @@ -4286,6 +4340,17 @@ "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", @@ -4965,6 +5030,14 @@ "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", @@ -5256,6 +5329,24 @@ "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", @@ -5307,7 +5398,6 @@ "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" }, @@ -6166,12 +6256,66 @@ "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", @@ -6186,6 +6330,31 @@ "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", diff --git a/package.json b/package.json index bf9adea..beaae09 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index 17712b8..2e55600 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -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); diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 2edfb8b..6bea195 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -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( diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 10088ad..00ecb01 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -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( diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 3b1ccc5..4005234 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -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 = useDebounce( useCallback( (evt) => { const term = evt.target.value; - search(term); + if (term) search(term); + else resetSearch(); }, - [search] + [search, resetSearch] ), { wait: 200 } ); diff --git a/src/app/hooks/useAsyncSearch.ts b/src/app/hooks/useAsyncSearch.ts index b083a19..d0e73e7 100644 --- a/src/app/hooks/useAsyncSearch.ts +++ b/src/app/hooks/useAsyncSearch.ts @@ -25,11 +25,13 @@ export type UseAsyncSearchResult = items: TSearchItem[]; }; +export type SearchResetHandler = () => void; + export const useAsyncSearch = ( list: TSearchItem[], getItemStr: SearchItemStrGetter, options?: UseAsyncSearchOptions -): [UseAsyncSearchResult | undefined, AsyncSearchHandler] => { +): [UseAsyncSearchResult | undefined, AsyncSearchHandler, SearchResetHandler] => { const [result, setResult] = useState>(); const [searchCallback, terminateSearch] = useMemo(() => { @@ -51,7 +53,7 @@ export const useAsyncSearch = ( const handleResult: ResultHandler = (results, query) => setResult({ query, - items: results, + items: [...results], }); return AsyncSearch(list, handleMatch, handleResult, options); @@ -60,15 +62,16 @@ export const useAsyncSearch = ( 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 = ( [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 index 0000000..754007a --- /dev/null +++ b/src/app/hooks/useIntersectionObserver.ts @@ -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(); + + 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 index 0000000..dd0a3df --- /dev/null +++ b/src/app/hooks/usePowerLevelTags.ts @@ -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] + ); +}; diff --git a/src/app/hooks/useResizeObserver.ts b/src/app/hooks/useResizeObserver.ts index 69ec65d..1e0fc72 100644 --- a/src/app/hooks/useResizeObserver.ts +++ b/src/app/hooks/useResizeObserver.ts @@ -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; }; diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 544d97a..df36901 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -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([]); - 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 index 0000000..4afe908 --- /dev/null +++ b/src/app/hooks/useScreenSize.ts @@ -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 index 0000000..2718e92 --- /dev/null +++ b/src/app/organisms/room/MembersDrawer.css.ts @@ -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 index 0000000..d50c366 --- /dev/null +++ b/src/app/organisms/room/MembersDrawer.tsx @@ -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(null); + const searchInputRef = useRef(null); + const scrollTopAnchorRef = useRef(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({ + 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 = []; + 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 = useDebounce( + useCallback( + (evt) => { + if (evt.target.value) search(evt.target.value); + else resetSearch(); + }, + [search, resetSearch] + ), + { wait: 200 } + ); + + const handleMemberClick: MouseEventHandler = (evt) => { + const btn = evt.currentTarget as HTMLButtonElement; + const userId = btn.getAttribute('data-user-id'); + openProfileViewer(userId, room.roomId); + }; + + return ( + +
+ + + + {`${millify(room.getJoinedMemberCount(), { precision: 1 })} Members`} + + + + + Invite Member + + } + > + {(triggerRef) => ( + openInviteUser(room.roomId)} + > + + + )} + + + +
+ + + + + Filter + + + {(open, setOpen) => ( + setOpen(false), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + }} + > + + {membershipFilterMenu.map((menuItem) => ( + { + setFilter((f) => ({ ...f, membershipFilter: menuItem })); + setOpen(false); + }} + > + {menuItem.name} + + ))} + + + } + > + {(anchorRef) => ( + setOpen(!open)} + variant={filter.membershipFilter.color} + radii="400" + outlined + after={} + > + {filter.membershipFilter.name} + + )} + + )} + + + {(open, setOpen) => ( + setOpen(false), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + }} + > + + {sortFilterMenu.map((menuItem) => ( + { + setFilter((f) => ({ ...f, sortFilter: menuItem })); + setOpen(false); + }} + > + {menuItem.name} + + ))} + + + } + > + {(anchorRef) => ( + setOpen(!open)} + variant="Surface" + radii="400" + outlined + after={} + > + {`Order: ${filter.sortFilter.name}`} + + )} + + )} + + + + + + Search + } + after={ + result && ( + 0 ? 'Success' : 'Critical'} + size="400" + radii="Pill" + onClick={() => { + if (searchInputRef.current) searchInputRef.current.value = ''; + resetSearch(); + }} + after={} + > + {`${result.items.length || 'No'} ${ + result.items.length === 1 ? 'Result' : 'Results' + }`} + + ) + } + /> + + + {!onTop && ( + + virtualizer.scrollToOffset(0)} + variant="Surface" + radii="Pill" + outlined + size="300" + aria-label="Scroll to Top" + > + + + + )} + + {!fetchingMembers && !result && processMembers.length === 0 && ( + + {`No "${filter.membershipFilter.name}" Members`} + + )} + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const tagOrMember = PLTagOrRoomMember[vItem.index]; + if (!('userId' in tagOrMember)) { + return ( + + {tagOrMember.name} + + ); + } + + const member = tagOrMember; + const avatarUrl = member.getAvatarUrl( + mx.baseUrl, + 100, + 100, + 'crop', + undefined, + false + ); + + return ( + + {avatarUrl ? ( + + ) : ( + + {member.name[0]} + + )} + + } + > + + {member.name} + + + ); + })} +
+
+ + {fetchingMembers && ( + + + + )} +
+
+
+
+ ); +} diff --git a/src/app/organisms/room/Room.jsx b/src/app/organisms/room/Room.jsx index 9d861c9..0603b98 100644 --- a/src/app/organisms/room/Room.jsx +++ b/src/app/organisms/room/Room.jsx @@ -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() { - {isDrawer && } + + {screenSize === ScreenSize.Desktop && isDrawer && ( + <> + + + + )} ); } diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx index 39016ad..e869e16 100644 --- a/src/app/organisms/room/RoomInput.tsx +++ b/src/app/organisms/room/RoomInput.tsx @@ -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; @@ -161,15 +161,8 @@ export const RoomInput = forwardRef( 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( > {(anchorRef) => ( <> - {!mobile && ( + {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} @@ -532,7 +525,7 @@ export const RoomInput = forwardRef( setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" @@ -542,7 +535,9 @@ export const RoomInput = forwardRef( diff --git a/src/app/organisms/room/RoomViewContent.jsx b/src/app/organisms/room/RoomViewContent.jsx index fe598bf..5726fe1 100644 --- a/src/app/organisms/room/RoomViewContent.jsx +++ b/src/app/organisms/room/RoomViewContent.jsx @@ -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) => { diff --git a/src/app/organisms/room/RoomViewHeader.jsx b/src/app/organisms/room/RoomViewHeader.jsx index 46a6ba0..6571241 100644 --- a/src/app/organisms/room/RoomViewHeader.jsx +++ b/src/app/organisms/room/RoomViewHeader.jsx @@ -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) => , - ); + openReusableContextMenu('bottom', getEventCords(e, '.ic-btn'), (closeMenu) => ( + + )); }; return ( @@ -90,18 +94,34 @@ function RoomViewHeader({ roomId }) { > - {twemojify(roomName)} + + {twemojify(roomName)} + - {mx.isRoomEncrypted(roomId) === false && toggleRoomSettings(tabText.SEARCH)} tooltip="Search" src={SearchIC} />} - - toggleRoomSettings(tabText.MEMBERS)} tooltip="Members" src={UserIC} /> + {mx.isRoomEncrypted(roomId) === false && ( + toggleRoomSettings(tabText.SEARCH)} + tooltip="Search" + src={SearchIC} + /> + )} { + setPeopleDrawer((t) => !t); + }} + tooltip="People" + src={UserIC} /> + toggleRoomSettings(tabText.MEMBERS)} + tooltip="Members" + src={UserIC} + /> + ); } diff --git a/src/app/state/hooks/settings.ts b/src/app/state/hooks/settings.ts index 3f4dab6..43b5655 100644 --- a/src/app/state/hooks/settings.ts +++ b/src/app/state/hooks/settings.ts @@ -10,9 +10,9 @@ export const useSetSetting = ( ) => { const setterAtom = useMemo( () => - atom(null, (get, set, value) => { + atom 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 = ( export const useSetting = ( settingsAtom: WritableAtom, key: K -): [Settings[K], SetAtom] => { +): [Settings[K], SetAtom 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]; };