Skip to content
576 linesCodeBlameRaw
1import {
2 Activity,
3 BellOff,
4 BellRing,
5 BookOpen,
6 CircleUserRound,
7 Ellipsis,
8 Keyboard,
9 LayoutGrid,
10 LifeBuoy,
11 LogOut,
12 Moon,
13 Settings,
14 Smile,
15 Sun,
16} from "lucide-react";
17import { Dialog as Primitive } from "radix-ui";
18import { type ReactNode, useEffect, useRef, useState } from "react";
19import { Link, useLocation, useNavigate, useSubmit } from "react-router";
20
21import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
22
23import { appIcon, pinnedApp } from "./apps";
24import { CountBadge, ShortcutsDialog } from "./rail";
25import { Mark } from "./logo";
26
27import { Avatar } from "./ui/avatar";
28import { TabStrip } from "./ui/tab-strip";
29import { StatusDialog } from "./presence";
30import { ThemeSwitch } from "./theme-switch";
31import { type BuiltinApp, type PinnableApp, appOf } from "../lib/apps";
32import { setPresence, useOwnPresence } from "../lib/notify-client";
33import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
34import { STATUS_URL } from "../lib/status";
35import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
36import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
37import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
38
39/**
40 * The phone's layout (below 768px): a bottom bar of apps with More for
41 * the rest, the mode's sidebar as a drawer from the left, and each tab a
42 * list that pushes its detail full screen. A computer has the rail.
43 */
44
45/** Whether a path is one conversation, which takes the whole screen on a phone. */
46export function isConversation(pathname: string): boolean {
47 return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname);
48}
49
50/**
51 * Follows the visual viewport, which shrinks when the keyboard opens: its
52 * height and top go on the page as `--vv-height` and `--vv-top`, what
53 * the keyboard covers as `--keyboard-inset`, and
54 * `data-keyboard="open"` on <html> while the keyboard is up, so a
55 * composer can sit just above it and the tab bar can step aside.
56 */
57export function useVisualViewport() {
58 useEffect(() => {
59 const viewport = window.visualViewport;
60 if (!viewport) return;
61 const root = document.documentElement;
62 const update = () => {
63 root.style.setProperty("--vv-height", `${viewport.height}px`);
64 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
65 // How much of the bottom the keyboard covers, for a sheet to sit on it.
66 root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`);
67 const keyboard = window.innerHeight - viewport.height > 140;
68 if (keyboard) root.dataset.keyboard = "open";
69 else delete root.dataset.keyboard;
70 };
71 update();
72 viewport.addEventListener("resize", update);
73 viewport.addEventListener("scroll", update);
74 return () => {
75 viewport.removeEventListener("resize", update);
76 viewport.removeEventListener("scroll", update);
77 };
78 }, []);
79}
80
81/**
82 * Swipe from the left edge to go back, as on a phone's own apps: a touch
83 * that starts within 28px of the edge and travels 80px right, more across
84 * than down. Going back is the browser's own back, so history stays true.
85 */
86export function useSwipeBack(onBack: () => void) {
87 const start = useRef<{ x: number; y: number } | null>(null);
88 return {
89 onTouchStart: (event: React.TouchEvent) => {
90 const touch = event.touches[0];
91 start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null;
92 },
93 onTouchMove: (event: React.TouchEvent) => {
94 const touch = event.touches[0];
95 if (!start.current || !touch) return;
96 const dx = touch.clientX - start.current.x;
97 const dy = Math.abs(touch.clientY - start.current.y);
98 if (dx > 80 && dx > dy * 1.5) {
99 start.current = null;
100 onBack();
101 }
102 },
103 onTouchEnd: () => {
104 start.current = null;
105 },
106 };
107}
108
109/** Back, as the browser would; to `fallback` when this page was the first one opened. */
110export function useBack(fallback: string) {
111 const navigate = useNavigate();
112 return () => {
113 const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
114 if (index > 0) navigate(-1);
115 else navigate(fallback, { replace: true });
116 };
117}
118
119type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } };
120
121/**
122 * The bottom bar of a phone, flat along the bottom edge under a rule, as
123 * the rail stands flat beside the page on a computer: Home, Chat,
124 * Notifications and Code (for a member with Code access), each
125 * with what is unread, and More. The tab you are in has the rail's own
126 * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up
127 * and inside a conversation. The tab you are in, tapped again, opens its
128 * mode's menu (its sidebar).
129 */
130export function BottomBar({
131 user,
132 workspace,
133 pins,
134 unread,
135 onReselect,
136}: {
137 user: User;
138 workspace: Membership;
139 /** The apps pinned to this person's rail here, for the More sheet. */
140 pins: PinnableApp[];
141 unread: { notifications: number; chat: number; mentions: number };
142 /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
143 onReselect?: () => void;
144}) {
145 const { pathname } = useLocation();
146 const [more, setMore] = useState(false);
147 useEffect(() => setMore(false), [pathname]);
148 const slug = workspace.slug;
149 const code = hasCodeAccess(workspace);
150 const mode = modeOf(pathname, slug);
151 const tabs: Tab[] = [
152 { key: "home", label: "Home" },
153 { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } },
154 { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } },
155 ...(code ? [{ key: "code" as const, label: "Code" }] : []),
156 ];
157 const inTabs = tabs.some((tab) => tab.key === mode);
158 if (isConversation(pathname)) return null;
159 return (
160 <>
161 <nav
162 aria-label="Apps"
163 className="fixed inset-x-0 bottom-0 z-40 h-(--tabbar-h) border-t border-line bg-rail pb-[env(safe-area-inset-bottom)] md:hidden in-data-[keyboard=open]:hidden"
164 >
165 <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}>
166 {tabs.map((tab) => {
167 const current = mode === tab.key && !more;
168 const count = tab.badge?.count ?? 0;
169 const to = modeHome(tab.key, slug);
170 return (
171 <li key={tab.key} className="min-w-0">
172 <Link
173 to={to}
174 prefetch="intent"
175 aria-current={current ? "page" : undefined}
176 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
177 onClick={(event) => {
178 // The tab you are in, tapped again: its menu, as a phone's own apps do.
179 // Chat's first page on a phone is its sidebar already.
180 if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === to)) return;
181 event.preventDefault();
182 onReselect();
183 }}
184 className={BAR_ITEM}
185 >
186 <BarIcon current={current}>
187 {appIcon(tab.key as BuiltinApp, 20)}
188 {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />}
189 </BarIcon>
190 <BarLabel current={current}>{tab.label}</BarLabel>
191 </Link>
192 </li>
193 );
194 })}
195 <li className="min-w-0">
196 <button type="button" aria-expanded={more} aria-haspopup="dialog" onClick={() => setMore(true)} className={BAR_ITEM}>
197 <BarIcon current={more || !inTabs}>
198 <Ellipsis size={20} />
199 </BarIcon>
200 <BarLabel current={more || !inTabs}>More</BarLabel>
201 </button>
202 </li>
203 </ul>
204 </nav>
205 <MoreSheet open={more} onOpenChange={setMore} user={user} workspace={workspace} pins={pins} />
206 </>
207 );
208}
209
210const BAR_ITEM = "group relative flex h-full w-full flex-col items-center justify-center gap-1 outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset";
211
212/** A tab's icon, marked as the rail marks the app you are in (ui/sidebar.tsx, `size: icon`). */
213function BarIcon({ current, children }: { current: boolean; children: ReactNode }) {
214 return (
215 <span
216 className={`relative flex h-7 w-11 items-center justify-center rounded-[10px] transition-colors group-active:bg-sidebar-accent/60 ${current ? "bg-sidebar-accent text-fg" : "text-muted"}`}
217 >
218 {children}
219 </span>
220 );
221}
222
223/** A tab's name, centred under its icon; a long one gives way rather than crowding its neighbours. */
224function BarLabel({ current, children }: { current: boolean; children: ReactNode }) {
225 return (
226 <span className={`block w-full truncate px-0.5 text-center text-[0.6875rem] leading-none font-medium max-[380px]:text-[0.625rem] ${current ? "text-fg" : "text-faint"}`}>
227 {children}
228 </span>
229 );
230}
231
232/** A big tile in the More sheet. */
233function MoreTile({ to, icon, label, onClick, disabled, note }: { to?: string; icon: ReactNode; label: string; onClick?: () => void; disabled?: boolean; note?: string }) {
234 const inner = (
235 <>
236 <span className={`flex size-11 items-center justify-center rounded-[10px] ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span>
237 <span className={`line-clamp-2 w-full text-xs leading-tight max-[380px]:text-[0.6875rem] ${disabled ? "text-faint" : "text-fg-soft"}`}>{label}</span>
238 {note && <span className="-mt-0.5 rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">{note}</span>}
239 </>
240 );
241 const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-md px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
242 if (to) {
243 return (
244 <Link to={to} className={className}>
245 {inner}
246 </Link>
247 );
248 }
249 return (
250 <button type="button" onClick={onClick} disabled={disabled} aria-disabled={disabled} className={className}>
251 {inner}
252 </button>
253 );
254}
255
256/**
257 * More, from the bottom bar: a flat panel standing on it, with g1t's
258 * mark at its top and a grid of big tiles: all apps,
259 * your pinned apps, Artifacts, People, Workspace, and "You and
260 * help", which opens your account's sheet.
261 */
262function MoreSheet({ open, onOpenChange, user, workspace, pins }: { open: boolean; onOpenChange: (open: boolean) => void; user: User; workspace: Membership; pins: PinnableApp[] }) {
263 const slug = workspace.slug;
264 const [account, setAccount] = useState(false);
265 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
266 return (
267 <>
268 <Primitive.Root open={open} onOpenChange={onOpenChange}>
269 <Primitive.Portal>
270 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out md:hidden" />
271 <Primitive.Content
272 aria-describedby={undefined}
273 onOpenAutoFocus={(event) => event.preventDefault()}
274 className="fixed inset-x-0 bottom-(--tabbar-h) z-50 max-h-[calc(100dvh-var(--tabbar-h)-3.5rem)] overflow-y-auto border-t border-line bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
275 >
276 <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" />
277 <div className="mb-2 flex items-center gap-2 px-1">
278 <Mark tight className="h-4 w-auto" />
279 <Primitive.Title className="text-sm font-semibold">More</Primitive.Title>
280 <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span>
281 </div>
282 <div className="grid grid-cols-4 gap-1.5">
283 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
284 {shown.map((app) => (
285 <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
286 ))}
287 <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" />
288 <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" />
289 <MoreTile to={appOf("workspace").path(slug)} icon={appIcon("workspace", 20)} label="Workspace" />
290 <MoreTile
291 icon={<Avatar name={user.username} image={user.avatar} size={24} />}
292 label="You and help"
293 onClick={() => {
294 onOpenChange(false);
295 setAccount(true);
296 }}
297 />
298 </div>
299 </Primitive.Content>
300 </Primitive.Portal>
301 </Primitive.Root>
302 <AccountSheet open={account} onOpenChange={setAccount} user={user} />
303 </>
304 );
305}
306/**
307 * A sheet that rises from the bottom of a phone, with a handle to drag it
308 * back down. Escape, a tap outside or a drag of more than 90px closes it.
309 */
310export function BottomSheet({
311 open,
312 onOpenChange,
313 title,
314 children,
315}: {
316 open: boolean;
317 onOpenChange: (open: boolean) => void;
318 title: string;
319 children: ReactNode;
320}) {
321 const [drag, setDrag] = useState(0);
322 const from = useRef<number | null>(null);
323 useEffect(() => {
324 if (!open) setDrag(0);
325 }, [open]);
326 const handlers = {
327 onPointerDown: (event: React.PointerEvent) => {
328 from.current = event.clientY;
329 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
330 },
331 onPointerMove: (event: React.PointerEvent) => {
332 if (from.current == null) return;
333 setDrag(Math.max(0, event.clientY - from.current));
334 },
335 onPointerUp: () => {
336 if (drag > 90) onOpenChange(false);
337 else setDrag(0);
338 from.current = null;
339 },
340 };
341 return (
342 <Primitive.Root open={open} onOpenChange={onOpenChange}>
343 <Primitive.Portal>
344 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
345 <Primitive.Content
346 aria-describedby={undefined}
347 onOpenAutoFocus={(event) => event.preventDefault()}
348 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
349 className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]"
350 >
351 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
352 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
353 </div>
354 <Primitive.Title className="sr-only">{title}</Primitive.Title>
355 <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div>
356 </Primitive.Content>
357 </Primitive.Portal>
358 </Primitive.Root>
359 );
360}
361
362/** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
363export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
364 const className = "flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
365 const inner = (
366 <>
367 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
368 <span className="min-w-0 grow truncate">{children}</span>
369 {end}
370 </>
371 );
372 if (to) {
373 return (
374 <Link to={to} className={className} onClick={onClick}>
375 {inner}
376 </Link>
377 );
378 }
379 if (href) {
380 return (
381 <a href={href} className={className}>
382 {inner}
383 </a>
384 );
385 }
386 return (
387 <button type="button" onClick={onClick} className={className}>
388 {inner}
389 </button>
390 );
391}
392
393function SheetGroup({ title, children }: { title: string; children: ReactNode }) {
394 return (
395 <section className="mt-2 border-t border-line pt-2">
396 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
397 {children}
398 </section>
399 );
400}
401
402/** Your status, away and pausing notifications, as the account menu has them on a computer. */
403function OwnPresenceRows({ onEdit }: { onEdit: () => void }) {
404 const me = useOwnPresence();
405 const now = Date.now();
406 const status = liveStatus(me?.status, now);
407 const paused = dndOn(me, now);
408 const away = me?.away_manual === true;
409 return (
410 <>
411 <SheetRow icon={status?.emoji ? <span className="text-lg leading-none">{status.emoji}</span> : <Smile />} onClick={onEdit}>
412 {status ? status.text || "Status set" : "Set a status"}
413 </SheetRow>
414 <SheetRow icon={away ? <Sun /> : <Moon />} onClick={() => void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" })}>
415 {away ? "Set yourself active" : "Set yourself away"}
416 </SheetRow>
417 {paused ? (
418 <SheetRow
419 icon={<BellRing />}
420 end={me?.dnd_until ? <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now))}</span> : null}
421 onClick={() => void setPresence({ dnd_until: null }, { dnd_until: null })}
422 >
423 Resume notifications
424 </SheetRow>
425 ) : (
426 <SheetRow
427 icon={<BellOff />}
428 onClick={() => {
429 const until = pauseUntil("1h", new Date());
430 void setPresence({ dnd_until: until }, { dnd_until: until });
431 }}
432 >
433 Pause notifications for 1 hour
434 </SheetRow>
435 )}
436 </>
437 );
438}
439
440/**
441 * You and help, on a phone: your status, your profile and settings, the
442 * documentation, support, status and the keyboard's shortcuts, and signing
443 * out, as the account menu has them on a computer.
444 */
445export function AccountSheet({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: User }) {
446 const submit = useSubmit();
447 const [editing, setEditing] = useState(false);
448 const [keys, setKeys] = useState(false);
449 return (
450 <>
451 <BottomSheet open={open} onOpenChange={onOpenChange} title="You and help">
452 <div className="flex items-center gap-3 px-3 pt-1 pb-3">
453 <Avatar name={user.username} image={user.avatar} size={44} />
454 <div className="min-w-0">
455 <p className="truncate text-base font-semibold">@{shownUsername(user)}</p>
456 </div>
457 </div>
458 <SheetGroup title="You">
459 <OwnPresenceRows
460 onEdit={() => {
461 onOpenChange(false);
462 setEditing(true);
463 }}
464 />
465 <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}>
466 Your profile
467 </SheetRow>
468 <SheetRow to="/settings" icon={<Settings />}>
469 Your settings
470 </SheetRow>
471 </SheetGroup>
472 <SheetGroup title="Appearance">
473 <ThemeSwitch size="large" className="mx-3 mt-1 mb-1.5" />
474 </SheetGroup>
475 <SheetGroup title="Help">
476 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
477 Documentation
478 </SheetRow>
479 <SheetRow to="/support" icon={<LifeBuoy />}>
480 Support
481 </SheetRow>
482 <SheetRow href={STATUS_URL} icon={<Activity />}>
483 Status
484 </SheetRow>
485 <SheetRow
486 icon={<Keyboard />}
487 onClick={() => {
488 onOpenChange(false);
489 setKeys(true);
490 }}
491 >
492 Keyboard shortcuts
493 </SheetRow>
494 </SheetGroup>
495 <SheetGroup title="Account">
496 <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}>
497 Sign out
498 </SheetRow>
499 </SheetGroup>
500 </BottomSheet>
501 <StatusDialog open={editing} onOpenChange={setEditing} />
502 <ShortcutsDialog open={keys} onOpenChange={setKeys} />
503 </>
504 );
505}
506
507/** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */
508const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = {
509 Code: "code",
510 Issues: "issues",
511 Deployments: "deployments",
512 Observability: "observability",
513 Security: "security",
514 Insights: "insights",
515};
516const SOON_PAGES: Record<string, ProjectPage> = Object.fromEntries(
517 ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])),
518);
519
520/**
521 * A project's pages on a phone, under its name: the sidebar's own list
522 * (lib/chrome.ts) as a row of tabs that scrolls sideways, the current one
523 * kept in view, so Issues and Pull requests are one tap away. From 768px
524 * the sidebar has them.
525 */
526export function ProjectStrip({
527 base,
528 member,
529 can,
530 settings,
531 counts,
532 always = false,
533}: {
534 /** `/<namespace>/<name>`. */
535 base: string;
536 member: boolean;
537 can?: Partial<Abilities>;
538 /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
539 settings?: boolean;
540 counts?: { issues: number; pulls: number };
541 /** On a computer too: for a visitor, who has no sidebar to list them. */
542 always?: boolean;
543}) {
544 const { pathname } = useLocation();
545 const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
546 const at = projectPageAt(rest, SOON_PAGES);
547 const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
548 ...(settings ?? member ? (["settings"] as const) : []),
549 ];
550 const pill = (current: boolean) =>
551 `flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm transition-colors ${
552 current ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted active:bg-raised/60"
553 }`;
554 return (
555 <TabStrip label="Project" className={`-mx-4 gap-1 px-4 ${always ? "" : "md:hidden"}`}>
556 {pages.map((page) => {
557 const current = at === page;
558 const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
559 const count = page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined;
560 return (
561 <Link
562 key={page}
563 to={link.path ? `${base}/${link.path}` : base}
564 prefetch="intent"
565 aria-current={current ? "page" : undefined}
566 data-active={current || undefined}
567 className={pill(current)}
568 >
569 {link.label}
570 {count != null && count > 0 && <span className="rounded-full bg-line px-1.5 text-xs tabular-nums text-muted">{count}</span>}
571 </Link>
572 );
573 })}
574 </TabStrip>
575 );
576}