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