Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

242 linesCodeBlameRaw
1import { Clock, MapPin, MessageSquare, UserRound, Users } from "lucide-react";
2import { type ReactNode, createContext, useContext, useEffect, useState } from "react";
3import { Link, useNavigate } from "react-router";
4
5import { shownUsername } from "@g1t/contracts";
6
7import { useChatSend } from "./actions";
8import { BottomSheet } from "../mobile";
9import { PresenceSummary, WithPresence } from "../presence";
10
11import { Avatar } from "../ui/avatar";
12import { Button } from "../ui/button";
13import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
14import { Skeleton } from "../ui/skeleton";
15import type { UserCard } from "../../lib/hovercard";
16import { localTime } from "../../lib/time-zone";
17
18/** A person's card as Chat reads it: the hovercard, and their teams here. */
19export type PersonCard = UserCard & { teams: { slug: string; name: string }[] };
20
21/** What a card needs from the conversation it opens in. */
22export type CardContextValue = {
23 slug: string;
24 /** Opens the profile panel beside the conversation, in place of its details. */
25 onViewProfile: (username: string) => void;
26};
27
28/** The conversation's card context; outside one, names are plain links. */
29export const CardContext = createContext<CardContextValue | null>(null);
30
31/** People's cards, read once per page and shared: hovering prefetches, opening uses it. */
32const cards = new Map<string, Promise<PersonCard | null>>();
33
34export function personCard(slug: string, username: string): Promise<PersonCard | null> {
35 const key = `${slug}:${username.toLowerCase()}`;
36 let found = cards.get(key);
37 if (!found) {
38 found = fetch(`/${slug}/-/chat/person/${encodeURIComponent(username)}`)
39 .then((response) => (response.ok ? (response.json() as Promise<PersonCard>) : null))
40 .catch(() => null);
41 cards.set(key, found);
42 }
43 return found;
44}
45
46function usePersonCard(slug: string, username: string, open: boolean): PersonCard | null | undefined {
47 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
48 useEffect(() => {
49 if (!open) return;
50 let live = true;
51 void personCard(slug, username).then((value) => {
52 if (live) setCard(value);
53 });
54 return () => {
55 live = false;
56 };
57 }, [slug, username, open]);
58 return card;
59}
60
61/** Opens (or makes) the direct message with someone, then goes there. */
62function useMessage(slug: string) {
63 const send = useChatSend(slug);
64 const navigate = useNavigate();
65 const [busy, setBusy] = useState(false);
66 return {
67 busy,
68 open: async (member: string) => {
69 setBusy(true);
70 const opened = await send<{ to: string }>({ intent: "dm", members: [member] });
71 setBusy(false);
72 if (opened.ok) navigate(opened.value.to);
73 },
74 };
75}
76
77function PersonBody({
78 username,
79 display,
80 avatar,
81 ctx,
82 open,
83 close,
84}: {
85 username: string;
86 display: string;
87 avatar: string | null;
88 ctx: CardContextValue;
89 open: boolean;
90 close: () => void;
91}) {
92 const card = usePersonCard(ctx.slug, username, open);
93 const message = useMessage(ctx.slug);
94 const [now, setNow] = useState(() => Date.now());
95 useEffect(() => setNow(Date.now()), [open]);
96 const time = card ? localTime(card.timezone, now) : null;
97 return (
98 <div className="w-full">
99 <div className="flex items-start gap-3.5">
100 <WithPresence person={{ username }} size={56} ring="var(--color-raised)">
101 <Avatar name={username} image={card?.avatar ?? avatar} size={56} />
102 </WithPresence>
103 <div className="min-w-0 pt-1">
104 <p className="truncate text-base font-semibold">{card?.name?.trim() || display}</p>
105 <p className="truncate font-mono text-xs text-muted">
106 @{card ? shownUsername(card) : username}
107 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
108 </p>
109 </div>
110 </div>
111 {/* Live: their status and whether they are here (components/presence.tsx). */}
112 <PresenceSummary person={{ username }} className="mt-3 text-[0.8125rem] text-muted" />
113 <div className="mt-3 space-y-1.5 text-[0.8125rem] text-muted">
114 {card === undefined ? (
115 <>
116 <Skeleton className="h-3 w-40" />
117 <Skeleton className="h-3 w-28" />
118 </>
119 ) : (
120 <>
121 {card?.bio && <p className="line-clamp-2 text-fg-soft">{card.bio}</p>}
122 {card && card.teams.length > 0 && (
123 <p className="flex items-center gap-2">
124 <Users size={13} className="shrink-0 text-faint" />
125 <span className="truncate">{card.teams.map((team) => team.name).join(", ")}</span>
126 </p>
127 )}
128 {time && (
129 <p className="flex items-center gap-2" suppressHydrationWarning>
130 <Clock size={13} className="shrink-0 text-faint" />
131 {time} local time
132 </p>
133 )}
134 {card?.location && (
135 <p className="flex items-center gap-2">
136 <MapPin size={13} className="shrink-0 text-faint" />
137 <span className="truncate">{card.location}</span>
138 </p>
139 )}
140 </>
141 )}
142 </div>
143 <div className="mt-4 flex gap-2">
144 <Button
145 type="button"
146 disabled={message.busy}
147 onClick={() => void message.open(`user:${username}`)}
148 variant="accent"
149 className="grow rounded-lg max-md:h-11"
150 >
151 <MessageSquare size={15} />
152 {message.busy ? "Opening…" : "Message"}
153 </Button>
154 <Button
155 type="button"
156 onClick={() => {
157 close();
158 ctx.onViewProfile(username);
159 }}
160 variant="outline"
161 className="grow rounded-lg max-md:h-11"
162 >
163 <UserRound size={15} />
164 View profile
165 </Button>
166 </div>
167 </div>
168 );
169}
170
171/**
172 * A name or avatar that opens the member's card: a popover beside it, or a
173 * sheet from the bottom on a phone: Message and View profile (a panel
174 * beside the conversation). Hovering or focusing it reads the card ahead.
175 * Outside a conversation it is a plain link; an old agent's name (agents
176 * no longer exist) is plain text.
177 */
178export function MemberCard({
179 member,
180 children,
181 className = "",
182 label,
183}: {
184 member: { kind: "user" | "agent" | "system"; name: string; id?: string; display_name?: string; avatar?: string | null };
185 children: ReactNode;
186 className?: string;
187 label?: string;
188}) {
189 const ctx = useContext(CardContext);
190 const [open, setOpen] = useState(false);
191 const [sheet, setSheet] = useState(false);
192 // An old agent, or @g1t: no profile to open.
193 if (member.kind !== "user") {
194 return (
195 <span className={className} aria-label={label}>
196 {children}
197 </span>
198 );
199 }
200 if (!ctx) {
201 return (
202 <Link to={`/u/${member.name}`} className={className} aria-label={label}>
203 {children}
204 </Link>
205 );
206 }
207 const prefetch = () => void personCard(ctx.slug, member.name);
208 const body = (close: () => void, isOpen: boolean) => (
209 <PersonBody username={member.name} display={member.display_name ?? member.name} avatar={member.avatar ?? null} ctx={ctx} open={isOpen} close={close} />
210 );
211 return (
212 <>
213 <Popover open={open} onOpenChange={setOpen}>
214 <PopoverTrigger asChild>
215 <button
216 type="button"
217 aria-label={label ?? `${member.display_name ?? member.name}'s card`}
218 onPointerEnter={prefetch}
219 onFocus={prefetch}
220 onTouchStart={prefetch}
221 onClick={(event) => {
222 // A phone: the card is a sheet from the bottom instead.
223 if (window.matchMedia("(max-width: 767px)").matches) {
224 event.preventDefault();
225 setSheet(true);
226 }
227 }}
228 className={`cursor-pointer text-left ${className}`}
229 >
230 {children}
231 </button>
232 </PopoverTrigger>
233 <PopoverContent align="start" className="w-80 p-4">
234 {body(() => setOpen(false), open)}
235 </PopoverContent>
236 </Popover>
237 <BottomSheet open={sheet} onOpenChange={setSheet} title={`${member.display_name ?? member.name}`}>
238 <div className="px-2 pt-1 pb-2">{body(() => setSheet(false), sheet)}</div>
239 </BottomSheet>
240 </>
241 );
242}