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.

624 linesCodeBlameRaw
1import { useSidebarLocation } from "../../lib/use-sidebar-location";
2import { ArrowLeft, BellOff, Check, ChevronDown, Compass, Globe, Hash, Lock, Pin, PinOff, Search, X } from "lucide-react";
3import { ContextMenu } from "radix-ui";
4import { type ReactNode, useEffect, useState } from "react";
5import { NavLink, useFetcher, useNavigate } from "react-router";
6
7import type { Channel, ChannelInvitation, ChatSidebarEntry, GuestChannel } from "@g1t/contracts";
8
9import { conversationCache } from "./conversation-cache";
10import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
11import { DndMenu } from "./dnd-menu";
12import { MemberAvatar } from "./marks";
13import { PersonStatusEmoji } from "../presence";
14import { Avatar } from "../ui/avatar";
15import { Badge } from "../ui/badge";
16import { Button } from "../ui/button";
17import { Hint } from "../ui/hint";
18import { Skeleton } from "../ui/skeleton";
19import { type ChatFilter, agentsOnlyDm, channelPath, filterEntries, g1tEntry, sections } from "../../lib/chat";
20import { feedConnected } from "../../lib/notify-client";
21import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
22import { setMutedConversations } from "../../lib/sound-events";
23
24/** How often the sidebar asks again while the tab is shown and the feed socket is down. */
25const REFRESH_MS = FALLBACK_REFRESH_MS;
26
27const ROW =
28 "group/row flex h-9 items-center gap-2 rounded-md pr-1.5 pl-2 text-[0.8125rem] transition-colors max-md:h-11 max-md:text-[0.9375rem] max-md:active:bg-raised";
29
30/** Unread on a row: lavender for a mention, quiet for the rest. */
31function Count({ entry }: { entry: ChatSidebarEntry | undefined }) {
32 if (!entry || entry.muted) return null;
33 const count = entry.mentions > 0 ? entry.mentions : entry.unread;
34 if (count <= 0) return null;
35 return (
36 <span
37 className={`min-w-5 shrink-0 rounded-full group-hover/row:invisible px-1.5 text-center text-[0.6875rem] leading-4.5 font-semibold tabular-nums ${
38 entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"
39 }`}
40 >
41 {count}
42 </span>
43 );
44}
45
46/**
47 * Chat mode's sidebar: what you pinned, the workspace's own channels (and a
48 * way to browse the rest), its public channels, then direct messages, the
49 * latest first. Sections fold, and
50 * stay folded on this device. All, Unread and Mentions filter every section. It refreshes
51 * itself while the tab is shown, so unread counts move without a reload.
52 */
53export function ChatSidebar({ slug: home, heading = true }: { slug: string; heading?: boolean }) {
54 const data = useChatData();
55 // The workspace whose chat is open: another one's, for a guest there.
56 const slug = data?.slug ?? home;
57 const { sidebar, refresh } = useChatSidebar();
58 const send = useChatSend(slug);
59 const [filter, setFilter] = useState<ChatFilter>("all");
60 const [query, setQuery] = useState("");
61 const navigate = useNavigate();
62 const { pathname } = useSidebarLocation();
63
64 useEffect(() => {
65 if (!data) return;
66 // Notify: counts arrive live over the feed socket; this asks again only while it is down.
67 const tick = () => {
68 if (document.visibilityState === "visible" && !feedConnected()) refresh();
69 };
70 const timer = setInterval(tick, REFRESH_MS);
71 document.addEventListener("visibilitychange", tick);
72 return () => {
73 clearInterval(timer);
74 document.removeEventListener("visibilitychange", tick);
75 };
76 }, [data, refresh]);
77
78 // Pins show at once and go back if the service refuses.
79 const [pins, setPins] = useState<Map<string, boolean>>(new Map());
80 useEffect(() => setPins(new Map()), [sidebar]);
81 // Sounds leave muted conversations alone (lib/sound-events.ts).
82 useEffect(() => {
83 if (sidebar) setMutedConversations(sidebar.entries.filter((e) => e.muted).map((e) => e.channel.id));
84 }, [sidebar]);
85 const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e));
86 // The open conversation stays listed even when it is an old one with an agent alone, which Jump to otherwise finds.
87 const currentId =
88 entries.find((e) => pathname === channelPath(slug, e.channel) || pathname.startsWith(`${channelPath(slug, e.channel)}/`))?.channel.id ?? null;
89 const shown = filterEntries(entries, filter, query, currentId);
90 const { pinned, channels, publicChannels, dms } = sections(shown);
91 const loading = !data;
92
93 // Folded sections, remembered per workspace on this device. A folded
94 // section still shows what is unread and the conversation open now.
95 const [folded, setFolded] = useState<ReadonlySet<string>>(new Set());
96 useEffect(() => setFolded(readFolded(slug)), [slug]);
97 const fold = (id: string) =>
98 setFolded((now) => {
99 const next = new Set(now);
100 if (!next.delete(id)) next.add(id);
101 writeFolded(slug, next);
102 return next;
103 });
104 const keepShown = (entry: ChatSidebarEntry) => {
105 const to = channelPath(slug, entry.channel);
106 return pathname === to || pathname.startsWith(`${to}/`) || (!entry.muted && (entry.unread > 0 || entry.mentions > 0));
107 };
108
109 const togglePin = async (entry: ChatSidebarEntry) => {
110 const next = !entry.starred;
111 setPins((now) => new Map(now).set(entry.channel.id, next));
112 const done = await send({ intent: "preferences", channel_id: entry.channel.id, starred: next });
113 if (!done.ok)
114 setPins((now) => {
115 const back = new Map(now);
116 back.delete(entry.channel.id);
117 return back;
118 });
119 };
120
121 const conversation = (entry: ChatSidebarEntry) => (
122 <ConversationRow key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} onPin={() => void togglePin(entry)} />
123 );
124
125 return (
126 // The colour behind it, for the dots cut out of people's avatars.
127 <div className="flex h-full flex-col [--chat-sidebar-bg:var(--color-sidebar)]">
128 {heading && (
129 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
130 <h2 className="text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Chat</h2>
131 <div className="flex items-center gap-0.5">
132 <DndMenu />
133 <NewMessageButton slug={slug} />
134 </div>
135 </div>
136 )}
137 {sidebar?.guest && sidebar.workspace && <GuestBanner workspace={sidebar.workspace} home={home} />}
138 <div className="space-y-2.5 px-2.5 pt-3">
139 <div className="flex items-center gap-2">
140 <label className="flex h-8 min-w-0 grow items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70">
141 <Search size={14} className="shrink-0 text-faint" />
142 <input
143 value={query}
144 onChange={(event) => setQuery(event.target.value)}
145 onKeyDown={(event) => {
146 if (event.key === "Enter") {
147 const first = [...pinned, ...channels, ...publicChannels, ...dms][0];
148 if (first) {
149 navigate(channelPath(slug, first.channel));
150 setQuery("");
151 }
152 } else if (event.key === "Escape") setQuery("");
153 }}
154 placeholder="Jump to channel or person"
155 aria-label="Jump to channel or person"
156 autoComplete="off"
157 data-1p-ignore
158 className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
159 />
160 </label>
161 {/* A phone has no heading row: Do not disturb sits by the search box instead. */}
162 {!heading && <DndMenu className="size-8 max-md:size-11" />}
163 </div>
164 <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
165 {(["all", "unread", "mentions"] as const).map((key) => (
166 <button
167 key={key}
168 type="button"
169 role="radio"
170 aria-checked={filter === key}
171 onClick={() => setFilter(key)}
172 className={`h-6 rounded-[5px] text-xs font-medium capitalize transition-colors ${
173 filter === key ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
174 }`}
175 >
176 {key}
177 </button>
178 ))}
179 </div>
180 </div>
181 {/* A phone's list (no heading): room at the end so the compose button never covers the last row. */}
182 <nav
183 aria-label="Conversations"
184 className={`min-h-0 grow overflow-y-auto px-2.5 pt-3 scrollbar-thin ${heading ? "pb-4" : "pb-[calc(5.5rem+env(safe-area-inset-bottom))]"}`}
185 >
186 {loading ? (
187 <SidebarSkeleton />
188 ) : !sidebar ? (
189 <p className="px-2 py-3 text-xs leading-relaxed text-faint">Chat didn&apos;t answer. Your conversations will show here once it does.</p>
190 ) : (
191 <>
192 {/* Open to anyone on g1t, apart from the workspace's own, and first: only when there are some, or for a guest, to find them. */}
193 {(publicChannels.length > 0 || (sidebar.guest && filter === "all" && !query)) && (
194 <Section id="public" title="Public channels" folded={folded} onFold={fold} keep={publicChannels.filter(keepShown).map(conversation)}>
195 {publicChannels.map(conversation)}
196 {sidebar.guest && filter === "all" && !query && (
197 <>
198 {publicChannels.length === 0 && <Empty>You&apos;re in none of its public channels yet.</Empty>}
199 <BrowseRow slug={slug} count={sidebar.browsable} guest />
200 </>
201 )}
202 </Section>
203 )}
204 {pinned.length > 0 && (
205 <Section id="pinned" title="Pinned" folded={folded} onFold={fold} keep={pinned.filter(keepShown).map(conversation)}>
206 {pinned.map(conversation)}
207 </Section>
208 )}
209 {/* A guest has only the channels they were asked into here; with none, the public ones are all there is. */}
210 {(!sidebar.guest || channels.length > 0) && (
211 <Section
212 id="channels"
213 title="Channels"
214 folded={folded}
215 onFold={fold}
216 action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />}
217 keep={channels.filter(keepShown).map(conversation)}
218 >
219 {channels.map(conversation)}
220 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
221 {!sidebar.guest && filter === "all" && !query && <BrowseRow slug={slug} count={sidebar.browsable} />}
222 </Section>
223 )}
224 {(sidebar.invitations?.length ?? 0) > 0 && filter === "all" && !query && (
225 <Section id="invitations" title="Invitations" folded={folded} onFold={fold}>
226 {sidebar.invitations!.map((invitation) => (
227 <InvitationRow key={invitation.id} invitation={invitation} onAnswered={refresh} />
228 ))}
229 </Section>
230 )}
231 {(sidebar.elsewhere?.length ?? 0) > 0 && (
232 <Section id="elsewhere" title="Other workspaces" folded={folded} onFold={fold}>
233 {sidebar
234 .elsewhere!.filter(
235 (entry) =>
236 (filter === "all" || (filter === "unread" ? entry.unread > 0 : entry.mentions > 0)) &&
237 (!query || entry.channel.name?.includes(query.toLowerCase())),
238 )
239 .map((entry) => (
240 <ElsewhereRow key={entry.channel.id} entry={entry} pathname={pathname} />
241 ))}
242 </Section>
243 )}
244 {/* A guest has no direct messages here: those are for the workspace's members. */}
245 {!sidebar.guest && (
246 <Section
247 id="people"
248 title="Direct messages"
249 folded={folded}
250 onFold={fold}
251 action={<NewMessageButton slug={slug} />}
252 keep={dms.filter(keepShown).map(conversation)}
253 >
254 {/* @g1t, before the first word to it: its direct message opens on first use. */}
255 {filter === "all" && !query && !entries.some(g1tEntry) && (
256 <ExtraRow
257 to={`/${slug}/-/chat/dm/g1t`}
258 icon={<MemberAvatar member={{ kind: "system", id: "g1t", name: "g1t", avatar: null }} size={18} ring="var(--chat-sidebar-bg)" />}
259 >
260 g1t
261 </ExtraRow>
262 )}
263 {dms.map(conversation)}
264 {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
265 </Section>
266 )}
267 {shown.length === 0 && (filter !== "all" || query) && (
268 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
269 )}
270 </>
271 )}
272 </nav>
273 </div>
274 );
275}
276
277const FOLDED_KEY = (slug: string) => `g1t:chat-folded:${slug}`;
278
279/** The sections folded in this workspace, as last left; none if storage is not there. */
280function readFolded(slug: string): ReadonlySet<string> {
281 try {
282 const saved = JSON.parse(localStorage.getItem(FOLDED_KEY(slug)) ?? "[]");
283 return new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === "string") : []);
284 } catch {
285 return new Set();
286 }
287}
288
289function writeFolded(slug: string, folded: ReadonlySet<string>) {
290 try {
291 if (folded.size === 0) localStorage.removeItem(FOLDED_KEY(slug));
292 else localStorage.setItem(FOLDED_KEY(slug), JSON.stringify([...folded]));
293 } catch {
294 // Private windows and blocked storage: folding still works until a reload.
295 }
296}
297
298/**
299 * A titled part of the sidebar that folds away. Folded, it keeps `keep`
300 * showing: the unread conversations and the open one.
301 */
302function Section({
303 id,
304 title,
305 folded,
306 onFold,
307 action,
308 keep,
309 children,
310}: {
311 id: string;
312 title: string;
313 folded: ReadonlySet<string>;
314 onFold: (id: string) => void;
315 action?: ReactNode;
316 keep?: ReactNode[];
317 children: ReactNode;
318}) {
319 const open = !folded.has(id);
320 const list = `chat-section-${id}`;
321 return (
322 <section className="mb-3">
323 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
324 <button
325 type="button"
326 onClick={() => onFold(id)}
327 aria-expanded={open}
328 aria-controls={list}
329 className="flex min-w-0 grow items-center gap-1 rounded px-1 py-1 text-left text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none max-md:text-[0.8125rem]"
330 >
331 <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
332 {title}
333 </button>
334 <span className="opacity-70 transition-opacity group-hover/head:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">{action}</span>
335 </div>
336 {(open || (keep && keep.length > 0)) && (
337 <ul id={list} className="space-y-px">
338 {open ? children : keep}
339 </ul>
340 )}
341 </section>
342 );
343}
344
345/** A quieter row at the end of a section: somewhere to go rather than a conversation. */
346function ExtraRow({ to, icon, count, countLabel, children }: { to: string; icon: ReactNode; count?: number; countLabel?: string; children: ReactNode }) {
347 return (
348 <li>
349 <NavLink
350 to={to}
351 end
352 className={({ isActive }) => `${ROW} ${isActive ? "bg-sidebar-accent text-fg" : "text-faint hover:bg-sidebar-accent/60 hover:text-muted"}`}
353 >
354 <span className="flex w-4.5 shrink-0 justify-center">{icon}</span>
355 <span className="min-w-0 grow truncate">{children}</span>
356 {count != null && (
357 <span className="shrink-0 text-xs tabular-nums">
358 {count}
359 {countLabel && <span className="sr-only"> {countLabel}</span>}
360 </span>
361 )}
362 </NavLink>
363 </li>
364 );
365}
366
367/** Browse channels, with how many more there are to join; a guest browses the public ones. */
368function BrowseRow({ slug, count, guest = false }: { slug: string; count: number; guest?: boolean }) {
369 return (
370 <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={count > 0 ? count : undefined} countLabel="to join">
371 {guest ? "Browse public channels" : "Browse channels"}
372 </ExtraRow>
373 );
374}
375
376function Empty({ children }: { children: ReactNode }) {
377 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
378}
379
380/** Pin or unpin, on hover or focus; the same as the context menu's first item. */
381function PinButton({ pinned, onPin, label }: { pinned: boolean; onPin: () => void; label: string }) {
382 return (
383 <Hint label={pinned ? "Unpin" : "Pin to the top"}>
384 <Button
385 type="button"
386 aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`}
387 onClick={(event) => {
388 event.preventDefault();
389 event.stopPropagation();
390 onPin();
391 }}
392 variant="secondary"
393 size="icon"
394 className="absolute top-1/2 right-1 hidden size-6 -translate-y-1/2 rounded text-faint group-hover/row:flex group-focus-within/row:flex hover:text-fg [@media(hover:none)]:hidden"
395 >
396 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
397 </Button>
398 </Hint>
399 );
400}
401
402/** Right-click on a row: pin it, or open it. */
403function RowMenu({ pinned, onPin, to, children }: { pinned: boolean; onPin: () => void; to?: string; children: ReactNode }) {
404 const navigate = useNavigate();
405 const item =
406 "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-[0.8125rem] text-fg/90 outline-none select-none data-highlighted:bg-line data-highlighted:text-fg [&_svg]:size-4 [&_svg]:text-muted";
407 return (
408 <ContextMenu.Root>
409 <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
410 <ContextMenu.Portal>
411 <ContextMenu.Content className="z-50 min-w-44 rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40 data-[state=open]:animate-pop-in">
412 <ContextMenu.Item className={item} onSelect={onPin}>
413 {pinned ? <PinOff /> : <Pin />}
414 {pinned ? "Unpin" : "Pin to the top"}
415 </ContextMenu.Item>
416 {to && (
417 <ContextMenu.Item className={item} onSelect={() => navigate(to)}>
418 <Hash />
419 Open
420 </ContextMenu.Item>
421 )}
422 </ContextMenu.Content>
423 </ContextMenu.Portal>
424 </ContextMenu.Root>
425 );
426}
427
428/**
429 * Reads a conversation ahead, on hover, focus or the start of a touch, so
430 * opening it draws at once (its route's `clientLoader` keeps it).
431 */
432function usePrefetchConversation(to: string) {
433 const fetcher = useFetcher();
434 const load = () => {
435 if (fetcher.state === "idle" && fetcher.data === undefined && !conversationCache.has(to)) fetcher.load(to);
436 };
437 return { onPointerEnter: load, onFocus: load, onTouchStart: load };
438}
439
440/** One channel or direct message: its mark, its name, its unread count, and a pin. */
441function ConversationRow({ entry, slug, pathname, onPin }: { entry: ChatSidebarEntry; slug: string; pathname: string; onPin: () => void }) {
442 const to = channelPath(slug, entry.channel);
443 const current = pathname === to || pathname.startsWith(`${to}/`);
444 const prefetch = usePrefetchConversation(to);
445 const unread = !entry.muted && (entry.unread > 0 || entry.mentions > 0);
446 // People first: an old agent (agents no longer exist) shows only when nobody else is there.
447 const other = entry.others.find((m) => m.kind === "user") ?? entry.others[0];
448 // An old conversation with an agent alone (lib/chat.ts): still here to read, dimmed.
449 const former = agentsOnlyDm(entry);
450 const icon =
451 entry.channel.kind === "dm" && other ? (
452 // One person: their dot, cut out of the sidebar behind it.
453 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
454 ) : (
455 <ChannelMark channel={entry.channel} />
456 );
457 return (
458 <li className="group/row relative">
459 <RowMenu pinned={entry.starred} onPin={onPin} to={to}>
460 <NavLink
461 to={to}
462 {...prefetch}
463 aria-current={current ? "page" : undefined}
464 className={`${ROW} ${
465 current
466 ? "bg-sidebar-accent text-fg"
467 : unread
468 ? "text-fg hover:bg-sidebar-accent/60"
469 : entry.muted || former
470 ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted"
471 : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
472 }`}
473 >
474 <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"} ${former ? "opacity-60" : ""}`}>{icon}</span>
475 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
476 {entry.title}
477 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
478 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
479 )}
480 </span>
481 {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
482 <Count entry={entry} />
483 </NavLink>
484 </RowMenu>
485 <PinButton pinned={entry.starred} onPin={onPin} label={entry.title} />
486 </li>
487 );
488}
489
490/** A channel's mark: a lock when private, a globe when open to anyone on g1t, else a hash. */
491export function ChannelMark({ channel, size = 15 }: { channel: Pick<Channel, "private" | "visibility">; size?: number }) {
492 if (channel.private || channel.visibility === "private") return <Lock size={size - 1} aria-label="Private channel" />;
493 if (channel.visibility === "public") return <Globe size={size - 1} aria-label="Public channel, open to anyone on g1t" />;
494 return <Hash size={size} aria-label="Workspace channel" />;
495}
496
497/**
498 * At the top of a guest's sidebar: whose chat this is, that they are not
499 * in the workspace, and the way back to their own.
500 */
501function GuestBanner({ workspace, home }: { workspace: { slug: string; name: string; avatar: string | null }; home: string }) {
502 const own = home.toLowerCase() !== workspace.slug;
503 return (
504 <div className="mx-2.5 mt-1 rounded-lg bg-surface p-2.5 ring-1 ring-line">
505 <div className="flex items-center gap-2">
506 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
507 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{workspace.name}</span>
508 <Badge>Guest</Badge>
509 </div>
510 <p className="mt-1.5 text-xs leading-relaxed text-muted">
511 You&apos;re not a member of this workspace. You see only the channels you&apos;re in and its public ones.
512 </p>
513 {own && (
514 <NavLink to={`/${home}/-/chat`} className="mt-2 inline-flex items-center gap-1 text-xs text-accent hover:underline">
515 <ArrowLeft size={12} />
516 Back to your chat
517 </NavLink>
518 )}
519 </div>
520 );
521}
522
523/** A channel of another workspace the viewer is a guest in: its workspace's icon, its name, its count. */
524function ElsewhereRow({ entry, pathname }: { entry: GuestChannel; pathname: string }) {
525 const to = `/${entry.workspace.slug}/-/chat/${entry.channel.name}`;
526 const current = pathname === to;
527 const count = entry.mentions > 0 ? entry.mentions : entry.unread;
528 return (
529 <li>
530 <NavLink
531 to={to}
532 aria-current={current ? "page" : undefined}
533 className={`${ROW} ${current ? "bg-sidebar-accent text-fg" : count > 0 ? "text-fg hover:bg-sidebar-accent/60" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`}
534 >
535 <span className="flex w-4.5 shrink-0 justify-center">
536 <Avatar name={entry.workspace.slug} image={entry.workspace.avatar} size={16} square />
537 </span>
538 <span className={`min-w-0 grow truncate ${count > 0 ? "font-semibold" : ""}`}>
539 #{entry.channel.name}
540 <span className="ml-1.5 text-xs font-normal text-faint">{entry.workspace.name}</span>
541 </span>
542 {count > 0 && (
543 <span
544 className={`min-w-5 shrink-0 rounded-full px-1.5 text-center text-[0.6875rem] leading-4.5 font-semibold tabular-nums ${entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}
545 >
546 {count}
547 </span>
548 )}
549 </NavLink>
550 </li>
551 );
552}
553
554/** An invitation into another workspace's channel: who asked, and Join or Decline. */
555function InvitationRow({ invitation, onAnswered }: { invitation: ChannelInvitation; onAnswered: () => void }) {
556 const navigate = useNavigate();
557 const [busy, setBusy] = useState(false);
558 const [error, setError] = useState<string | null>(null);
559 const answer = async (accept: boolean) => {
560 setBusy(true);
561 setError(null);
562 try {
563 const response = await fetch(`/${invitation.workspace.slug}/-/chat/api`, {
564 method: "POST",
565 headers: { "content-type": "application/json" },
566 body: JSON.stringify({ intent: "answer_invitation", id: invitation.id, accept }),
567 });
568 const result = (await response.json()) as { ok: boolean; value?: { to: string | null }; error?: { message: string } };
569 if (!result.ok) {
570 setError(result.error?.message ?? "That didn't work.");
571 return;
572 }
573 onAnswered();
574 if (accept && result.value?.to) navigate(result.value.to);
575 } catch {
576 setError("Couldn't reach g1t. Try again.");
577 } finally {
578 setBusy(false);
579 }
580 };
581 return (
582 <li className="rounded-md px-2 py-1.5 text-[0.8125rem]">
583 <div className="flex items-center gap-2">
584 <Avatar name={invitation.workspace.slug} image={invitation.workspace.avatar} size={16} square />
585 <span className="min-w-0 grow truncate">
586 #{invitation.channel.name} <span className="text-xs text-faint">{invitation.workspace.name}</span>
587 </span>
588 </div>
589 <p className="mt-0.5 pl-6 text-xs text-faint">Asked in as a guest by @{invitation.invited_by.display_username ?? invitation.invited_by.name}</p>
590 <div className="mt-1.5 flex gap-1.5 pl-6">
591 <Button type="button" size="xs" disabled={busy} onClick={() => void answer(true)}>
592 <Check size={12} />
593 Join
594 </Button>
595 <Button type="button" size="xs" variant="ghost" disabled={busy} onClick={() => void answer(false)}>
596 <X size={12} />
597 Decline
598 </Button>
599 </div>
600 {error && <p className="mt-1 pl-6 text-xs text-danger">{error}</p>}
601 </li>
602 );
603}
604
605function SidebarSkeleton() {
606 return (
607 <div className="space-y-2 px-2 pt-1" aria-busy="true">
608 <Skeleton className="h-3 w-16" />
609 {[70, 52, 84, 60].map((w) => (
610 <div key={w} className="flex h-6 items-center gap-2">
611 <Skeleton className="size-4 rounded" />
612 <Skeleton className="h-3" style={{ width: `${w}%` }} />
613 </div>
614 ))}
615 <Skeleton className="mt-4 h-3 w-24" />
616 {[64, 48, 72].map((w) => (
617 <div key={w} className="flex h-6 items-center gap-2">
618 <Skeleton className="size-4 rounded-full" />
619 <Skeleton className="h-3" style={{ width: `${w}%` }} />
620 </div>
621 ))}
622 </div>
623 );
624}