Spaces:
Sleeping
Sleeping
File size: 6,075 Bytes
04ba3a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../api.js";
/**
* UserAutocomplete β reusable user-search input with live suggestions.
*
* Loads the active-users roster once on mount and filters as the admin types.
* On pick, fires `onApply(user_id_hash)` β the hash is guaranteed to exist
* in the bandit/turn tables, so downstream filters can't miss the user just
* because the admin typed a display name like "Alex Chen".
*
* Props:
* value (string) β current selected user_id_hash / raw user_id
* onApply (string)β β called when admin picks a suggestion or hits Enter
* placeholder (string)
* allowEmpty (bool) β show a "(all users)" empty-state option
* suggestions (array?) β pre-loaded user list; if omitted, fetches itself
* className (string?) β additional class on the wrapper
*
* Matches:
* - case-insensitive substring on display_name
* - prefix match on user_id_hash
* - exact match on raw user_id (if you happen to type one)
*/
export default function UserAutocomplete({
value = "",
onApply,
placeholder = "Search by name or hash",
allowEmpty = false,
suggestions: providedSuggestions = null,
className = "",
}) {
const [draft, setDraft] = useState(value || "");
const [open, setOpen] = useState(false);
const [highlight, setHi] = useState(0);
const [loaded, setLoaded] = useState(providedSuggestions || []);
const wrapRef = useRef(null);
useEffect(() => { setDraft(value || ""); }, [value]);
// Load active users once if caller didn't provide them.
// Use a wide window so even dormant users show up in the suggestions.
useEffect(() => {
if (providedSuggestions !== null) {
setLoaded(providedSuggestions);
return;
}
let cancelled = false;
(async () => {
try {
const r = await api.activeUsers(3650, 0, 500); // ~10 years window
if (!cancelled) setLoaded(Array.isArray(r) ? r : []);
} catch { /* dropdown just shows no suggestions */ }
})();
return () => { cancelled = true; };
}, [providedSuggestions]);
// Click-outside closes dropdown
useEffect(() => {
if (!open) return;
function onDocClick(e) {
if (!wrapRef.current?.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const matches = useMemo(() => {
const q = draft.trim().toLowerCase();
if (!q) return loaded.slice(0, 8); // empty query: show top 8
const out = [];
const seen = new Set();
for (const u of loaded) {
const name = (u.display_name || "").toLowerCase();
const hash = (u.user_id_hash || "").toLowerCase();
if (name.includes(q) || hash.includes(q)) {
const key = u.user_id_hash || u.display_name;
if (seen.has(key)) continue;
seen.add(key);
out.push(u);
if (out.length >= 8) break;
}
}
return out;
}, [draft, loaded]);
useEffect(() => { setHi(0); }, [matches.length]);
function pick(user) {
setDraft(user.user_id_hash);
onApply?.(user.user_id_hash);
setOpen(false);
}
function commit() {
if (open && matches[highlight]) {
pick(matches[highlight]);
} else {
// Free text β let the backend's _resolve_user_hash handle it
onApply?.(draft.trim());
setOpen(false);
}
}
function onKeyDown(e) {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setHi((h) => (h + 1) % Math.max(matches.length, 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((h) => (h - 1 + Math.max(matches.length, 1)) % Math.max(matches.length, 1));
} else if (e.key === "Escape") {
setOpen(false);
}
}
return (
<span className={`user-autocomplete ${className}`} ref={wrapRef}>
<input
type="text"
value={draft}
onChange={(e) => { setDraft(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
/>
{draft && (
<button
type="button"
className="ua-clear"
onClick={() => { setDraft(""); onApply?.(""); setOpen(false); }}
title="Clear"
>
Γ
</button>
)}
{open && (
<ul className="ua-dropdown" role="listbox">
{allowEmpty && (
<li
role="option"
aria-selected={false}
className="ua-option ua-option-empty"
onMouseDown={(e) => { e.preventDefault(); pick({ user_id_hash: "", display_name: "all users" }); }}
>
<span className="opt-name">β all users β</span>
<span className="opt-topic">no filter</span>
</li>
)}
{matches.length === 0 && (
<li className="ua-option ua-empty">No matches</li>
)}
{matches.map((u, i) => (
<li
key={u.user_id_hash}
role="option"
aria-selected={i === highlight}
className={`ua-option ${i === highlight ? "active" : ""}`}
onMouseEnter={() => setHi(i)}
onMouseDown={(e) => { e.preventDefault(); pick(u); }}
>
<span className="opt-name">{u.display_name || shortHash(u.user_id_hash)}</span>
<code className="opt-hash">{shortHash(u.user_id_hash)}</code>
{u.top_topic && <span className="opt-topic">top: {u.top_topic}</span>}
{u.contact_ready && <span className="opt-ready" title="Contact-ready">β</span>}
</li>
))}
</ul>
)}
</span>
);
}
function shortHash(h) {
if (!h) return "";
return h.length > 14 ? `${h.slice(0, 14)}β¦` : h;
}
|