Skip to content
119 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)1import { useEffect, useId, useRef, useState } from "react";
2
3import type { PersonMatch } from "@g1t/contracts";
4
5import { Avatar, Input } from "./ui";
6import { UserCard } from "./user-card";
7import { PEOPLE_SEARCH_PATH, peopleQuery } from "../lib/people-search";
8
9/**
10 * The invite form's "who" field on People: type a username, a name or an
11 * email address. As someone types a name, people on g1t are offered by
12 * username and name, with their avatars and the card over each name; an
13 * email address is never looked up. Posts what is in the field as `name`.
14 */
15export function PeoplePicker({ name, placeholder }: { name: string; placeholder?: string }) {
16 const [text, setText] = useState("");
17 const [people, setPeople] = useState<PersonMatch[]>([]);
18 const [open, setOpen] = useState(false);
19 const [active, setActive] = useState(0);
20 const listId = useId();
21 const asked = useRef(0);
22
23 useEffect(() => {
24 const query = peopleQuery(text);
25 if (!query) {
26 setPeople([]);
27 return;
28 }
29 const ask = ++asked.current;
30 const timer = setTimeout(() => {
31 fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
32 .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
33 .then((found) => {
34 if (ask !== asked.current) return;
35 setPeople(found.people ?? []);
36 setActive(0);
37 })
38 .catch(() => {
39 if (ask === asked.current) setPeople([]);
40 });
41 }, 150);
42 return () => clearTimeout(timer);
43 }, [text]);
44
45 const choose = (person: PersonMatch) => {
46 setText(person.username);
47 setPeople([]);
48 setOpen(false);
49 };
50 const showing = open && people.length > 0;
51
52 return (
53 <div className="relative">
54 <Input
55 name={name}
56 required
57 maxLength={254}
58 autoComplete="off"
59 placeholder={placeholder}
60 value={text}
61 role="combobox"
62 aria-expanded={showing}
63 aria-controls={listId}
64 aria-autocomplete="list"
65 aria-activedescendant={showing ? `${listId}-${active}` : undefined}
66 onChange={(event) => {
67 setText(event.currentTarget.value);
68 setOpen(true);
69 }}
70 onFocus={() => setOpen(true)}
71 onBlur={() => setTimeout(() => setOpen(false), 150)}
72 onKeyDown={(event) => {
73 if (!showing) return;
74 if (event.key === "ArrowDown") {
75 event.preventDefault();
76 setActive((at) => (at + 1) % people.length);
77 } else if (event.key === "ArrowUp") {
78 event.preventDefault();
79 setActive((at) => (at - 1 + people.length) % people.length);
80 } else if (event.key === "Enter" && people[active] && people[active].username !== text.trim().replace(/^@+/, "")) {
81 event.preventDefault();
82 choose(people[active]);
83 } else if (event.key === "Escape") {
84 setOpen(false);
85 }
86 }}
87 />
88 {showing && (
89 <ul
90 id={listId}
91 role="listbox"
92 className="absolute top-full right-0 left-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-line bg-surface p-1 shadow-lg"
93 >
94 {people.map((person, index) => (
95 <li
96 key={person.username}
97 id={`${listId}-${index}`}
98 role="option"
99 aria-selected={index === active}
100 className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${index === active ? "bg-line text-fg" : "text-fg/90"}`}
101 onMouseEnter={() => setActive(index)}
102 // Before the input's blur, so the choice is not lost.
103 onMouseDown={(event) => {
104 event.preventDefault();
105 choose(person);
106 }}
107 >
108 <Avatar name={person.username} image={person.avatar} size={20} />
109 <UserCard username={person.username}>
110 <span className="font-mono">{person.username}</span>
111 </UserCard>
112 {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
113 </li>
114 ))}
115 </ul>
116 )}
117 </div>
118 );
119}

This file's history is long; its oldest lines are credited to the oldest commit read.