1 import { type FC, useMemo, useRef, useState } from 'react';
2 import { useSelector } from 'react-redux';
3 import type { List } from 'react-virtualized';
5 import { c } from 'ttag';
7 import { Button, CircleLoader } from '@proton/atoms';
8 import { Checkbox } from '@proton/components';
9 import { ButtonBar } from '@proton/pass/components/Layout/Button/ButtonBar';
10 import { ShareMemberAvatar } from '@proton/pass/components/Share/ShareMemberAvatar';
11 import { useDebouncedValue } from '@proton/pass/hooks/useDebouncedValue';
12 import { useInviteRecommendations } from '@proton/pass/hooks/useInviteRecommendations';
13 import { selectDefaultVault } from '@proton/pass/store/selectors';
14 import type { MaybeNull } from '@proton/pass/types';
15 import { isEmptyString } from '@proton/pass/utils/string/is-empty-string';
16 import clsx from '@proton/utils/clsx';
18 import { VirtualList } from '../Layout/List/VirtualList';
22 excluded: Set<string>;
23 selected: Set<string>;
25 onToggle: (email: string, selected: boolean) => void;
30 export const InviteRecommendations: FC<Props> = (props) => {
31 const { autocomplete, excluded, selected, onToggle } = props;
32 const [view, setView] = useState<MaybeNull<string>>(null);
33 const listRef = useRef<List>(null);
35 const startsWith = useDebouncedValue(autocomplete, 250);
36 const defaultVault = useSelector(selectDefaultVault);
37 const shareId = props.shareId ?? defaultVault?.shareId ?? '';
39 const { loadMore, state } = useInviteRecommendations(startsWith, { pageSize, shareId });
40 const { organization, emails, loading } = state;
42 const displayedEmails = useMemo(() => {
43 const startsWith = autocomplete.toLowerCase();
44 const displayed = organization !== null && view === organization.name ? organization.emails : emails;
46 return isEmptyString(startsWith)
48 : displayed.filter((email) => email.toLowerCase().startsWith(startsWith));
49 }, [emails, organization, view, autocomplete]);
51 /** Used to compute the virtual list min-height as this component
52 * may be wrapped in a scrollable element */
53 const maxVisibleItems = Math.min(displayedEmails.length, pageSize - 1);
57 <h2 className="text-lg text-bold color-weak pb-2 shrink-0">
58 {c('Title').t`Suggestions`} {loading && <CircleLoader size="small" className="ml-2" />}
61 {organization !== null && (
62 <ButtonBar className="anime-fade-in shrink-0 mb-3" size="small">
64 onClick={() => setView(null)}
65 selected={view === null}
66 className="flex-auto text-semibold"
70 // translator: this is a label to show recent emails
75 onClick={() => setView(organization.name)}
76 selected={view === organization.name}
77 className="flex-auto text-semibold"
86 className="flex-1 min-h-custom overflow-hidden"
87 style={{ '--min-h-custom': `${40 * maxVisibleItems + 15}px` }}
89 {displayedEmails.length === 0 && !loading ? (
90 <em className="color-weak anime-fade-in"> {c('Warning').t`No results`}</em>
95 /** recent emails are not paginated - only trigger a new paginated
96 * request if we have more organization suggestions to load */
97 if (view === organization?.name) loadMore();
100 rowRenderer={({ style, index, key }) => {
101 const email = displayedEmails[index];
102 const disabled = excluded.has(email);
104 <div style={style} key={key} className="flex anime-fade-in">
106 key={`suggestion-${view}-${index}`}
107 className={clsx('flex flex-row-reverse flex-1 ml-2', disabled && 'opacity-0')}
109 checked={selected.has(email) || disabled}
110 onChange={({ target }) => onToggle(email, target.checked)}
112 <div className="flex flex-nowrap items-center flex-1">
113 <ShareMemberAvatar value={email.toUpperCase().slice(0, 2) ?? ''} />
114 <div className="flex-1 text-ellipsis color-white mr-2">{email}</div>
120 rowCount={displayedEmails.length}