Skip to content
220 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
77
78function PersonBody({ username, display, avatar, ctx, open, close }: { username: string; display: string; avatar: string | null; ctx: CardContextValue; open: boolean; close: () => void }) {
79 const card = usePersonCard(ctx.slug, username, open);
80 const message = useMessage(ctx.slug);
81 const [now, setNow] = useState(() => Date.now());
82 useEffect(() => setNow(Date.now()), [open]);
83 const time = card ? localTime(card.timezone, now) : null;
84 return (
85 <div className="w-full">
86 <div className="flex items-start gap-3.5">
87 <WithPresence person={{ username }} size={56} ring="var(--color-raised)">
88 <Avatar name={username} image={card?.avatar ?? avatar} size={56} />
89 </WithPresence>
90 <div className="min-w-0 pt-1">
91 <p className="truncate text-base font-semibold">{card?.name?.trim() || display}</p>
92 <p className="truncate font-mono text-xs text-muted">
93 @{card ? shownUsername(card) : username}
94 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
95 </p>
96 </div>
97 </div>
98 {/* Live: their status and whether they are here (components/presence.tsx). */}
99 <PresenceSummary person={{ username }} className="mt-3 text-[0.8125rem] text-muted" />
100 <div className="mt-3 space-y-1.5 text-[0.8125rem] text-muted">
101 {card === undefined ? (
102 <>
103 <Skeleton className="h-3 w-40" />
104 <Skeleton className="h-3 w-28" />
105 </>
106 ) : (
107 <>
108 {card?.bio && <p className="line-clamp-2 text-fg-soft">{card.bio}</p>}
109 {card && card.teams.length > 0 && (
110 <p className="flex items-center gap-2">
111 <Users size={13} className="shrink-0 text-faint" />
112 <span className="truncate">{card.teams.map((team) => team.name).join(", ")}</span>
113 </p>
114 )}
115 {time && (
116 <p className="flex items-center gap-2" suppressHydrationWarning>
117 <Clock size={13} className="shrink-0 text-faint" />
118 {time} local time
119 </p>
120 )}
121 {card?.location && (
122 <p className="flex items-center gap-2">
123 <MapPin size={13} className="shrink-0 text-faint" />
124 <span className="truncate">{card.location}</span>
125 </p>
126 )}
127 </>
128 )}
129 </div>
130 <div className="mt-4 flex gap-2">
131 <Button type="button" disabled={message.busy} onClick={() => void message.open(`user:${username}`)} variant="accent" className="grow rounded-lg max-md:h-11">
132 <MessageSquare size={15} />
133 {message.busy ? "Opening…" : "Message"}
134 </Button>
135 <Button
136 type="button"
137 onClick={() => {
138 close();
139 ctx.onViewProfile(username);
140 }}
141 variant="outline" className="grow rounded-lg max-md:h-11">
142 <UserRound size={15} />
143 View profile
144 </Button>
145 </div>
146 </div>
147 );
148}
149
150/**
151 * A name or avatar that opens the member's card: a popover beside it, or a
152 * sheet from the bottom on a phone: Message and View profile (a panel
153 * beside the conversation). Hovering or focusing it reads the card ahead.
154 * Outside a conversation it is a plain link; an old agent's name (agents
155 * no longer exist) is plain text.
156 */
157export function MemberCard({
158 member,
159 children,
160 className = "",
161 label,
162}: {
163 member: { kind: "user" | "agent"; name: string; id?: string; display_name?: string; avatar?: string | null };
164 children: ReactNode;
165 className?: string;
166 label?: string;
167}) {
168 const ctx = useContext(CardContext);
169 const [open, setOpen] = useState(false);
170 const [sheet, setSheet] = useState(false);
171 if (member.kind === "agent") {
172 return (
173 <span className={className} aria-label={label}>
174 {children}
175 </span>
176 );
177 }
178 if (!ctx) {
179 return (
180 <Link to={`/u/${member.name}`} className={className} aria-label={label}>
181 {children}
182 </Link>
183 );
184 }
185 const prefetch = () => void personCard(ctx.slug, member.name);
186 const body = (close: () => void, isOpen: boolean) => (
187 <PersonBody username={member.name} display={member.display_name ?? member.name} avatar={member.avatar ?? null} ctx={ctx} open={isOpen} close={close} />
188 );
189 return (
190 <>
191 <Popover open={open} onOpenChange={setOpen}>
192 <PopoverTrigger asChild>
193 <button
194 type="button"
195 aria-label={label ?? `${member.display_name ?? member.name}'s card`}
196 onPointerEnter={prefetch}
197 onFocus={prefetch}
198 onTouchStart={prefetch}
199 onClick={(event) => {
200 // A phone: the card is a sheet from the bottom instead.
201 if (window.matchMedia("(max-width: 767px)").matches) {
202 event.preventDefault();
203 setSheet(true);
204 }
205 }}
206 className={`cursor-pointer text-left ${className}`}
207 >
208 {children}
209 </button>
210 </PopoverTrigger>
211 <PopoverContent align="start" className="w-80 p-4">
212 {body(() => setOpen(false), open)}
213 </PopoverContent>
214 </Popover>
215 <BottomSheet open={sheet} onOpenChange={setSheet} title={`${member.display_name ?? member.name}`}>
216 <div className="px-2 pt-1 pb-2">{body(() => setSheet(false), sheet)}</div>
217 </BottomSheet>
218 </>
219 );
220}