Skip to content
205 linesCodeBlameRaw
1import { AtSign, Mail, UserPlus } from "lucide-react";
2import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { MAX_INVITE_MESSAGE, type PersonMatch } from "@g1t/contracts";
6
7import { ErrorText, SubmitButton } from "./ui";
8import { Avatar } from "./ui/avatar";
9import { Button } from "./ui/button";
10import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
11import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
12import { Field, FieldDescription, FieldLabel } from "./ui/field";
13import { SelectField } from "./ui/select";
14import { Textarea } from "./ui/textarea";
15import { UserCard } from "./user-card";
16import { type InviteResult, workspaceInviteCopy } from "../lib/invites";
17import { PEOPLE_SEARCH_PATH, looksLikeEmail, peopleQuery } from "../lib/people-search";
18
19/**
20 * Inviting someone to the workspace, in a dialog opened from Members and
21 * from Invitations: who (a username, a name to search by, or an email
22 * address), the role they join with, and a note for the email. Posted to
23 * `action` as `action=add` (lib/member-invitations.server.ts); on success the
24 * dialog closes and `onInvited` is told who, so the page can say so.
25 */
26export function InviteDialog({
27 name,
28 action,
29 onInvited,
30 trigger,
31}: {
32 /** The workspace's display name. */
33 name: string;
34 /** The page's address, whose action handles `add`. */
35 action: string;
36 onInvited?: (who: string) => void;
37 /** The button that opens it; the Invite button when absent. */
38 trigger?: ReactNode;
39}) {
40 const [open, setOpen] = useState(false);
41 const fetcher = useFetcher<InviteResult>();
42 const copy = workspaceInviteCopy(name);
43 const result = fetcher.state === "idle" ? fetcher.data : undefined;
44 // Sent: the dialog closes and the page says so. Each opening starts clean.
45 const told = useRef<InviteResult | null>(null);
46 useEffect(() => {
47 if (!result || told.current === result) return;
48 told.current = result;
49 if ("invited" in result) {
50 setOpen(false);
51 onInvited?.(result.invited);
52 }
53 }, [result, onInvited]);
54 const error = result && "error" in result ? result : null;
55 const messageId = useId();
56 return (
57 <Dialog open={open} onOpenChange={setOpen}>
58 <DialogTrigger asChild>
59 {trigger ?? (
60 <Button type="button">
61 <UserPlus size={15} />
62 Invite
63 </Button>
64 )}
65 </DialogTrigger>
66 <DialogContent>
67 {open && (
68 <fetcher.Form method="post" action={action} className="grid gap-5">
69 <input type="hidden" name="action" value="add" />
70 <DialogHeader>
71 <DialogTitle>{copy.heading}</DialogTitle>
72 <DialogDescription>{copy.hint}</DialogDescription>
73 </DialogHeader>
74 <WhoField />
75 <Field>
76 <FieldLabel htmlFor={`${messageId}-role`}>Role</FieldLabel>
77 <SelectField
78 id={`${messageId}-role`}
79 name="role"
80 defaultValue="member"
81 options={[
82 { value: "member", label: "Member", description: `Works in ${name} with the base permission on its repositories.` },
83 { value: "owner", label: "Owner", description: "Everything a member can, and manages the workspace itself." },
84 ]}
85 />
86 </Field>
87 <Field>
88 <FieldLabel htmlFor={messageId}>
89 Message <span className="font-normal text-faint">(optional)</span>
90 </FieldLabel>
91 <Textarea id={messageId} name="message" maxLength={MAX_INVITE_MESSAGE} rows={3} placeholder={`A line for the email: why you'd like them in ${name}.`} />
92 <FieldDescription>Quoted in the invitation email, under your name.</FieldDescription>
93 </Field>
94 {error && <ErrorText>{error.error}</ErrorText>}
95 <DialogFooter>
96 <DialogClose asChild>
97 <Button type="button" variant="outline">
98 Cancel
99 </Button>
100 </DialogClose>
101 <SubmitButton fetcher={fetcher} match={{ action: "add" }} pending="Inviting…">
102 <UserPlus size={15} />
103 Invite
104 </SubmitButton>
105 </DialogFooter>
106 </fetcher.Form>
107 )}
108 </DialogContent>
109 </Dialog>
110 );
111}
112
113/**
114 * Who to invite: a search box that offers people on g1t by username and
115 * name as you type, each with their picture and card, and takes an email
116 * address as written. Only what a profile shows is offered, never anyone's
117 * address. Posts what is in the box as `member`.
118 */
119function WhoField() {
120 const [text, setText] = useState("");
121 const [people, setPeople] = useState<PersonMatch[]>([]);
122 const [chosen, setChosen] = useState<string | null>(null);
123 const asked = useRef(0);
124 const id = useId();
125
126 useEffect(() => {
127 const query = chosen === text.trim().replace(/^@+/, "") ? null : peopleQuery(text);
128 if (!query) {
129 setPeople([]);
130 return;
131 }
132 const ask = ++asked.current;
133 const timer = setTimeout(() => {
134 fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
135 .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
136 .then((found) => {
137 if (ask === asked.current) setPeople(found.people ?? []);
138 })
139 .catch(() => {
140 if (ask === asked.current) setPeople([]);
141 });
142 }, 150);
143 return () => clearTimeout(timer);
144 }, [text, chosen]);
145
146 const email = looksLikeEmail(text);
147 const listed = people.length > 0 && !email;
148 return (
149 <Field>
150 <FieldLabel htmlFor={id}>Who</FieldLabel>
151 <Command shouldFilter={false} className="rounded-md border border-line bg-bg has-[input:focus-visible]:border-accent-dim has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-accent/25">
152 <CommandInput
153 id={id}
154 name="member"
155 required
156 maxLength={254}
157 autoComplete="off"
158 placeholder="username, name or name@example.com"
159 value={text}
160 onValueChange={(value) => {
161 setText(value);
162 setChosen(null);
163 }}
164 className="h-9"
165 />
166 {listed && (
167 <CommandList aria-label="People on g1t" className="max-h-56 border-t border-line">
168 {people.map((person) => (
169 <CommandItem
170 key={person.username}
171 value={person.username}
172 onSelect={() => {
173 setText(person.username);
174 setChosen(person.username);
175 setPeople([]);
176 }}
177 >
178 <Avatar name={person.username} image={person.avatar} size={20} />
179 <UserCard username={person.username}>
180 <span className="font-mono">{person.username}</span>
181 </UserCard>
182 {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
183 </CommandItem>
184 ))}
185 </CommandList>
186 )}
187 </Command>
188 <FieldDescription className="flex items-center gap-1.5">
189 {email ? (
190 <>
191 <Mail size={12} className="shrink-0" />
192 Invited by email: {text.trim()} gets the invitation, and signs up first if it has no account.
193 </>
194 ) : chosen ? (
195 <>
196 <AtSign size={12} className="shrink-0" />
197 @{chosen} gets the invitation in their notifications and by email.
198 </>
199 ) : (
200 "People on g1t are offered as you type; an email address is sent as written."
201 )}
202 </FieldDescription>
203 </Field>
204 );
205}