Skip to content

Commit 93c192b

Browse files
authored
fix: πŸ› reduce search debounce (#537)
* fix: πŸ› reduce search debounce * fix: πŸ› singleuser action, isolated membersearchautocomplete * fix: πŸ› search input
1 parent 1fc40c7 commit 93c192b

5 files changed

Lines changed: 227 additions & 111 deletions

File tree

β€Žsrc/components/groups/add-member-dialog.tsxβ€Ž

Lines changed: 150 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
import { inviteGroupMember } from "@actions/group/add-member/action";
44
import { createGroupInvite } from "@actions/group/invite/create/action";
55
import { 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";
117
import {
128
Autocomplete,
139
Avatar,
@@ -22,6 +18,8 @@ import {
2218
} from "@mui/material";
2319
import { Check, Copy } from "lucide-react";
2420
import {
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 && (

β€Žsrc/components/groups/create-group-dialog.tsxβ€Ž

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,6 @@ export function CreateGroupDialog({
215215
selectedMembers={members}
216216
onSelectedMembersChange={setMembers}
217217
excludeUserIds={[]}
218-
searchDebounceMs={50}
219218
searchFieldLabel="Add Members"
220219
shareLink={{
221220
url: inviteLink,

β€Žsrc/lib/sql/like-pattern.tsβ€Ž

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/** Trim and collapse internal whitespace for user-facing search input. */
2+
export function normalizeSearchQuery(query: string): string {
3+
return query.trim().replace(/\s+/g, " ");
4+
}
5+
6+
/** Escape `\`, `%`, and `_` so they match literally in SQL LIKE/ILIKE. */
7+
export function escapeLikePattern(query: string): string {
8+
return query.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
9+
}
10+
11+
/** Builds a `%term%` ILIKE pattern, or `null` when the normalized query is too short. */
12+
export function toIlikeContainsPattern(query: string): string | null {
13+
const normalized = normalizeSearchQuery(query);
14+
if (normalized.length < 2) return null;
15+
return `%${escapeLikePattern(normalized)}%`;
16+
}

β€Žsrc/server/actions/user/search.tsβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import { getCurrentSession } from "@/lib/auth";
44
import {
55
searchUsersByDisplayName,
66
searchUsersByEmail,
7+
searchUsersByQuery,
78
searchUsersByUsername,
89
} from "@/server/data/user/queries";
910

11+
export async function searchUsers(query: string) {
12+
const { user } = await getCurrentSession();
13+
if (!user) return [];
14+
15+
return searchUsersByQuery(query, user.id);
16+
}
17+
1018
export async function searchUsersEmail(query: string) {
1119
const { user } = await getCurrentSession();
1220
if (!user) return [];

0 commit comments

Comments
Β (0)