"@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",
"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",
"@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",
"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",
);
}, [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);
return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
}, []);
- const [result, search] = useAsyncSearch(
+ const [result, search, resetSearch] = useAsyncSearch(
allRoomId,
useCallback(
(rId) => {
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(
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(
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 }
);
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(() => {
const handleResult: ResultHandler<TSearchItem> = (results, query) =>
setResult({
query,
- items: results,
+ items: [...results],
});
return AsyncSearch(list, handleMatch, handleResult, options);
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.
[terminateSearch]
);
- return [result, searchHandler];
+ return [result, searchHandler, resetHandler];
};
--- /dev/null
+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;
+};
--- /dev/null
+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]
+ );
+};
): 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;
};
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();
});
}
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;
};
--- /dev/null
+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;
+};
--- /dev/null
+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%',
+});
--- /dev/null
+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>
+ );
+}
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';
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({
roomTimeline: null,
eventId: null,
});
- const [isDrawer, setIsDrawer] = useState(settings.isPeopleDrawer);
+ const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+ const [screenSize] = useScreenSize();
const mx = initMatrix.matrixClient;
};
}, [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());
<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>
);
}
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>;
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);
>
{(anchorRef) => (
<>
- {!mobile && (
+ {!hideStickerBtn && (
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
<IconButton
ref={anchorRef}
aria-pressed={
- mobile ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+ hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
<Icon
src={Icons.Smile}
filled={
- mobile ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+ hideStickerBtn
+ ? !!emojiBoardTab
+ : emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>
}, [newEvent]);
useResizeObserver(
- roomInputRef.current,
useCallback((entries) => {
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
timelineScroll.scrollToBottom();
}
- }, [roomInputRef])
+ }, [roomInputRef]),
+ useCallback(() => roomInputRef.current, [roomInputRef]),
);
const listenKeyboard = useCallback((event) => {
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';
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 () => {
}, [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 (
>
<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>
);
}
) => {
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]
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];
};