Skip to content
72 linesCodeBlameRaw
1import { useEffect } from "react";
2import { redirect, useNavigate } from "react-router";
3
4import type { Route } from "./+types/index";
5import { ChatUnavailable, NoChannels } from "../../../components/chat/empty";
6import { ChatSidebar } from "../../../components/chat/sidebar";
7import { NewMessageButton } from "../../../components/chat/actions";
8import { isPhone } from "../../../lib/phone";
9import { channelPath } from "../../../lib/chat";
10import { LAST_CHAT_COOKIE, sidebarOrNull } from "../../../lib/chat.server";
11import { readCookie } from "../../../lib/mission";
12import { page } from "../../../lib/meta";
13import { chat, workspaceAgents } from "../../../lib/services.server";
14import { requireUser } from "../../../lib/session.server";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `Chat · ${params.owner} · g1t` });
18}
19
20/**
21 * Chat opens where it was left: the last conversation open in this
22 * workspace, else #general, else the first there is.
23 */
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = requireUser(context, request);
26 const slug = params.owner.toLowerCase();
27 // `?agent=<handle>`: the direct message with that agent, opened or made
28 // (the top bar's Ask g1t, and links to an agent from anywhere).
29 const asked = new URL(request.url).searchParams.get("agent");
30 if (asked) {
31 const listed = await workspaceAgents.list(slug, viewer).catch(() => null);
32 const agent = listed?.ok ? listed.value.find((a) => a.handle === asked.toLowerCase()) : null;
33 const dm = agent ? await chat.openDm(slug, viewer, [{ kind: "agent", id: agent.id }]).catch(() => null) : null;
34 if (dm?.ok) throw redirect(channelPath(slug, dm.value));
35 }
36 // A phone opens on the list of conversations; a wider screen on one.
37 const phone = isPhone(request.headers);
38 const last = readCookie(request.headers.get("cookie"), LAST_CHAT_COOKIE);
39 const remembered = last && last.startsWith(`/${slug}/-/chat/`) && !last.includes("..") ? last : null;
40 if (remembered && !phone) throw redirect(remembered);
41 const sidebar = await sidebarOrNull(slug, viewer);
42 if (!sidebar) return { unavailable: true, phone, to: null };
43 const entries = sidebar.entries;
44 const first =
45 entries.find((entry) => entry.channel.kind === "channel" && entry.channel.name === "general") ??
46 entries.find((entry) => entry.channel.kind === "channel") ??
47 entries[0];
48 const to = remembered ?? (first ? channelPath(slug, first.channel) : null);
49 if (to && !phone) throw redirect(to);
50 return { unavailable: false, phone, to };
51}
52
53export default function ChatIndex({ loaderData, params }: Route.ComponentProps) {
54 const navigate = useNavigate();
55 const { to } = loaderData;
56 // Told it was a phone, but wider: on to the conversation after all.
57 useEffect(() => {
58 if (to && window.matchMedia("(min-width: 768px)").matches) navigate(to, { replace: true });
59 }, [to, navigate]);
60 return (
61 <>
62 {/* A phone: the list of conversations, full screen. */}
63 <div className="h-[calc(100dvh-var(--topbar-h)-var(--tabbar-h))] md:hidden">
64 <ChatSidebar slug={params.owner.toLowerCase()} heading={false} />
65 <div className="fixed right-4 bottom-[calc(var(--tabbar-h)+1rem)] z-30">
66 <NewMessageButton slug={params.owner.toLowerCase()} variant="fab" />
67 </div>
68 </div>
69 <div className="max-md:hidden">{loaderData.unavailable ? <ChatUnavailable /> : to ? null : <NoChannels />}</div>
70 </>
71 );
72}