33import { inviteGroupMember } from "@actions/group/add-member/action" ;
44import { createGroupInvite } from "@actions/group/invite/create/action" ;
55import { inviteMeetingMembers } from "@actions/meeting/invite/action" ;
6- import {
7- searchUsersDisplay ,
8- searchUsersEmail ,
9- searchUsersUsername ,
10- } from "@actions/user/search" ;
6+ import { searchUsers } from "@actions/user/search" ;
117import {
128 Autocomplete ,
139 Avatar ,
@@ -22,6 +18,8 @@ import {
2218} from "@mui/material" ;
2319import { Check , Copy } from "lucide-react" ;
2420import {
21+ type HTMLAttributes ,
22+ memo ,
2523 useCallback ,
2624 useEffect ,
2725 useMemo ,
@@ -60,31 +58,68 @@ type MemberInviteFieldsProps = {
6058 shareLink ?: MemberInviteShareLink | null ;
6159} ;
6260
63- export function MemberInviteFields ( {
64- selectedMembers,
65- onSelectedMembersChange,
61+ type MemberSearchAutocompleteProps = {
62+ selectedMemberIds : Set < string > ;
63+ excludeUserIds : string [ ] ;
64+ searchDebounceMs : number ;
65+ searchFieldLabel : string ;
66+ onSelectMember : ( user : SearchUser ) => void ;
67+ } ;
68+
69+ const MemberSearchOption = memo ( function MemberSearchOption ( {
70+ option,
71+ ...optionProps
72+ } : HTMLAttributes < HTMLLIElement > & { option : SearchUser } ) {
73+ return (
74+ < li { ...optionProps } >
75+ < div className = "flex items-center gap-3" >
76+ < Avatar
77+ src = { option . profilePicture ?? undefined }
78+ slotProps = { {
79+ img : { referrerPolicy : "no-referrer" } ,
80+ } }
81+ >
82+ { getInitials ( option . email ) }
83+ </ Avatar >
84+ < div className = "flex-col" >
85+ < Typography color = "textPrimary" variant = "button" >
86+ { option . displayName }
87+ </ Typography >
88+ < div className = "flex gap-1" >
89+ < Typography color = "textSecondary" variant = "caption" >
90+ { option . username ? `@${ option . username } β’` : "" }
91+ </ Typography >
92+ < Typography color = "textSecondary" variant = "caption" >
93+ { option . email }
94+ </ Typography >
95+ </ div >
96+ </ div >
97+ </ div >
98+ </ li >
99+ ) ;
100+ } ) ;
101+
102+ function MemberSearchAutocomplete ( {
103+ selectedMemberIds,
66104 excludeUserIds,
67- searchDebounceMs = 150 ,
68- searchFieldLabel = "Search users" ,
69- shareLink,
70- } : MemberInviteFieldsProps ) {
71- const { showError } = useSnackbar ( ) ;
105+ searchDebounceMs,
106+ searchFieldLabel,
107+ onSelectMember,
108+ } : MemberSearchAutocompleteProps ) {
72109 const [ memberQuery , setMemberQuery ] = useState ( "" ) ;
73110 const [ searchResults , setSearchResults ] = useState < SearchUser [ ] > ( [ ] ) ;
111+ const [ isSearching , startSearchTransition ] = useTransition ( ) ;
74112 const searchTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
75113 const latestQueryRef = useRef ( "" ) ;
76- const [ copied , setCopied ] = useState ( false ) ;
77- const copiedTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
114+
115+ const excludeSet = useMemo ( ( ) => new Set ( excludeUserIds ) , [ excludeUserIds ] ) ;
78116
79117 useEffect ( ( ) => {
80118 return ( ) => {
81119 if ( searchTimeoutRef . current ) clearTimeout ( searchTimeoutRef . current ) ;
82- if ( copiedTimeoutRef . current ) clearTimeout ( copiedTimeoutRef . current ) ;
83120 } ;
84121 } , [ ] ) ;
85122
86- const excludeSet = useMemo ( ( ) => new Set ( excludeUserIds ) , [ excludeUserIds ] ) ;
87-
88123 const handleMemberSearch = useCallback (
89124 ( query : string ) => {
90125 setMemberQuery ( query ) ;
@@ -95,33 +130,102 @@ export function MemberInviteFields({
95130 }
96131
97132 if ( query . length < 2 ) {
98- setSearchResults ( [ ] ) ;
133+ startSearchTransition ( ( ) => setSearchResults ( [ ] ) ) ;
99134 return ;
100135 }
101136
102- searchTimeoutRef . current = setTimeout ( async ( ) => {
103- const [ resultsEmail , resultsDisplay , resultsUser ] = await Promise . all ( [
104- searchUsersEmail ( query ) ,
105- searchUsersDisplay ( query ) ,
106- searchUsersUsername ( query ) ,
107- ] ) ;
108-
109- if ( latestQueryRef . current !== query ) return ;
110- const seen = new Set < string > ( ) ;
111- const results = [
112- ...resultsDisplay ,
113- ...resultsUser ,
114- ...resultsEmail ,
115- ] . filter ( ( r ) => {
116- if ( seen . has ( r . id ) ) return false ;
117- seen . add ( r . id ) ;
118- return true ;
119- } ) ;
120-
121- setSearchResults ( results . filter ( ( r ) => ! excludeSet . has ( r . id ) ) ) ;
137+ searchTimeoutRef . current = setTimeout ( ( ) => {
138+ void ( async ( ) => {
139+ const results = await searchUsers ( query ) ;
140+ if ( latestQueryRef . current !== query ) return ;
141+
142+ startSearchTransition ( ( ) => {
143+ setSearchResults (
144+ results . filter (
145+ ( r ) => ! excludeSet . has ( r . id ) && ! selectedMemberIds . has ( r . id ) ,
146+ ) ,
147+ ) ;
148+ } ) ;
149+ } ) ( ) ;
122150 } , searchDebounceMs ) ;
123151 } ,
124- [ excludeSet , searchDebounceMs ] ,
152+ [ excludeSet , searchDebounceMs , selectedMemberIds ] ,
153+ ) ;
154+
155+ const options = useMemo (
156+ ( ) => searchResults . filter ( ( user ) => ! selectedMemberIds . has ( user . id ) ) ,
157+ [ searchResults , selectedMemberIds ] ,
158+ ) ;
159+
160+ return (
161+ < Autocomplete
162+ options = { options }
163+ loading = { isSearching }
164+ getOptionLabel = { ( option ) => option . email }
165+ filterOptions = { ( x ) => x }
166+ inputValue = { memberQuery }
167+ onInputChange = { ( _ , value , reason ) => {
168+ if ( reason !== "reset" ) handleMemberSearch ( value ) ;
169+ } }
170+ onChange = { ( _ , user ) => {
171+ if ( user ) {
172+ onSelectMember ( user ) ;
173+ setMemberQuery ( "" ) ;
174+ setSearchResults ( [ ] ) ;
175+ }
176+ } }
177+ value = { null }
178+ isOptionEqualToValue = { ( option , value ) => option . id === value . id }
179+ noOptionsText = {
180+ memberQuery . length < 2
181+ ? "Search via Email, Display Name, Username..."
182+ : "No users found"
183+ }
184+ slotProps = { {
185+ popper : {
186+ placement : "bottom-start" ,
187+ modifiers : [ { name : "flip" , enabled : false } ] ,
188+ sx : ( theme ) => ( { zIndex : theme . zIndex . modal + 1 } ) ,
189+ } ,
190+ } }
191+ renderInput = { ( params ) => (
192+ < TextField { ...params } label = { searchFieldLabel } size = "small" />
193+ ) }
194+ ListboxProps = { {
195+ style : { maxHeight : 140 , overflowY : "auto" } ,
196+ } }
197+ renderOption = { ( { key, ...optionProps } , option ) => (
198+ < MemberSearchOption
199+ key = { key ?? option . id }
200+ option = { option }
201+ { ...optionProps }
202+ />
203+ ) }
204+ />
205+ ) ;
206+ }
207+
208+ export function MemberInviteFields ( {
209+ selectedMembers,
210+ onSelectedMembersChange,
211+ excludeUserIds,
212+ searchDebounceMs = 100 ,
213+ searchFieldLabel = "Search users" ,
214+ shareLink,
215+ } : MemberInviteFieldsProps ) {
216+ const { showError } = useSnackbar ( ) ;
217+ const [ copied , setCopied ] = useState ( false ) ;
218+ const copiedTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
219+
220+ useEffect ( ( ) => {
221+ return ( ) => {
222+ if ( copiedTimeoutRef . current ) clearTimeout ( copiedTimeoutRef . current ) ;
223+ } ;
224+ } , [ ] ) ;
225+
226+ const selectedMemberIds = useMemo (
227+ ( ) => new Set ( selectedMembers . map ( ( m ) => m . id ) ) ,
228+ [ selectedMembers ] ,
125229 ) ;
126230
127231 const addMember = useCallback (
@@ -137,8 +241,6 @@ export function MemberInviteFields({
137241 profilePicture : user . profilePicture ?? null ,
138242 } ,
139243 ] ) ;
140- setMemberQuery ( "" ) ;
141- setSearchResults ( [ ] ) ;
142244 } ,
143245 [ selectedMembers , onSelectedMembersChange ] ,
144246 ) ;
@@ -162,69 +264,14 @@ export function MemberInviteFields({
162264 }
163265 } , [ shareLink ?. url , showError ] ) ;
164266
165- // Filter again at render time so members selected while a debounced search
166- // is in-flight are immediately hidden from the dropdown.
167- const options = searchResults . filter (
168- ( user ) => ! selectedMembers . some ( ( m ) => m . id === user . id ) ,
169- ) ;
170267 return (
171268 < div className = "flex flex-col gap-5 pt-1" >
172- < Autocomplete
173- options = { options }
174- getOptionLabel = { ( option ) => option . email }
175- filterOptions = { ( x ) => x }
176- inputValue = { memberQuery }
177- onInputChange = { ( _ , value , reason ) => {
178- if ( reason !== "reset" ) handleMemberSearch ( value ) ;
179- } }
180- onChange = { ( _ , user ) => {
181- if ( user ) addMember ( user ) ;
182- } }
183- value = { null }
184- isOptionEqualToValue = { ( option , value ) => option . id === value . id }
185- noOptionsText = {
186- memberQuery . length < 2 ? "Type to searchβ¦" : "No users found"
187- }
188- slotProps = { {
189- popper : {
190- placement : "bottom-start" ,
191- modifiers : [ { name : "flip" , enabled : false } ] ,
192- sx : ( theme ) => ( { zIndex : theme . zIndex . modal + 1 } ) ,
193- } ,
194- } }
195- renderInput = { ( params ) => (
196- < TextField { ...params } label = { searchFieldLabel } size = "small" />
197- ) }
198- ListboxProps = { {
199- style : { maxHeight : 140 , overflowY : "auto" } ,
200- } }
201- renderOption = { ( { key, ...optionProps } , option ) => (
202- < li key = { key ?? option . id } { ...optionProps } >
203- < div className = "flex items-center gap-3" >
204- < Avatar
205- src = { option . profilePicture ?? undefined }
206- slotProps = { {
207- img : { referrerPolicy : "no-referrer" } ,
208- } }
209- >
210- { getInitials ( option . email ) }
211- </ Avatar >
212- < div className = "flex-col" >
213- < Typography color = "textPrimary" variant = "button" >
214- { option . displayName }
215- </ Typography >
216- < div className = "flex gap-1" >
217- < Typography color = "textSecondary" variant = "caption" >
218- { option . username ? `@${ option . username } β’` : "" }
219- </ Typography >
220- < Typography color = "textSecondary" variant = "caption" >
221- { option . email }
222- </ Typography >
223- </ div >
224- </ div >
225- </ div >
226- </ li >
227- ) }
269+ < MemberSearchAutocomplete
270+ selectedMemberIds = { selectedMemberIds }
271+ excludeUserIds = { excludeUserIds }
272+ searchDebounceMs = { searchDebounceMs }
273+ searchFieldLabel = { searchFieldLabel }
274+ onSelectMember = { addMember }
228275 />
229276
230277 { selectedMembers . length > 0 && (
0 commit comments