Skip to content
97 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat and workspace agents: channels, DMs and named agents you talk to1import { type ShouldRevalidateFunctionArgs, data } from "react-router";
2
3import type { Channel, ChannelMember, ChatMessage, User } from "@g1t/contracts";
4
5import type { Route } from "./+types/channel";
6import { ChannelView } from "../../../components/chat/channel";
7import { ChatUnavailable } from "../../../components/chat/empty";
8import { channelPath } from "../../../lib/chat";
9import { rememberChat } from "../../../lib/chat.server";
10import { page } from "../../../lib/meta";
11import { chat } from "../../../lib/services.server";
12import { requireUser, roleIn } from "../../../lib/session.server";
13
14export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
15 const title = loaderData && "title" in loaderData ? loaderData.title : "Chat";
16 return page(args, { title: `${title} · ${params.owner} · g1t` });
17}
18
19export type ChannelData =
20 | { unavailable: true; title: string }
21 | {
22 unavailable: false;
23 title: string;
24 channel: Channel;
25 members: ChannelMember[];
26 messages: ChatMessage[];
27 older: string | null;
28 /** Whether the viewer is in it: a public channel can be read before joining. */
29 joined: boolean;
30 };
31
32/**
33 * One conversation: a channel by its name (`-/chat/general`) or a direct
34 * message by its id (`-/chat/dm/<id>`), with its latest messages. Live
35 * updates arrive over the socket once the page is open.
36 */
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = requireUser(context, request);
39 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
40 const slug = params.owner.toLowerCase();
41 const name = "channel" in params ? (params.channel as string | undefined) : undefined;
42 const fallbackTitle = name ? `#${name}` : "Direct message";
43 const read = await readConversation(slug, viewer, name, "id" in params ? (params.id as string | undefined) : undefined).catch(
44 (error: unknown) => {
45 console.error("chat: the conversation could not be read", error);
46 return "unavailable" as const;
47 },
48 );
49 if (read === "missing") throw data(null, { status: 404 });
50 if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
51 const secure = new URL(request.url).protocol === "https:";
52 return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
53}
54
55/** The conversation and its latest messages; "missing" for a 404, "unavailable" when chat does not answer. */
56async function readConversation(
57 slug: string,
58 viewer: User,
59 name: string | undefined,
60 byId: string | undefined,
61): Promise<Extract<ChannelData, { unavailable: false }> | "missing" | "unavailable"> {
62 // A channel's address is its name; a direct message's, its id.
63 const lookup = byId ? chat.channel(slug, byId, viewer) : name ? chat.channelByName(slug, name, viewer) : null;
64 if (!lookup) return "missing";
65 const detail = await lookup;
66 if (!detail.ok) return detail.error.code === "not_found" || detail.error.code === "forbidden" ? "missing" : "unavailable";
67 const history = await chat.messages(slug, detail.value.channel.id, viewer, { limit: 60 });
68 const { channel, members } = detail.value;
69 // A public channel can be read before joining it.
70 const joined = members.some((m) => m.member.kind === "user" && m.member.id === viewer.id);
71 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === viewer.id));
72 const title =
73 channel.kind === "channel"
74 ? `#${channel.name}`
75 : others.map((m) => m.member.display_name || m.member.name).join(", ") || "Direct message";
76 return {
77 unavailable: false,
78 title,
79 channel,
80 members,
81 messages: history.ok ? history.value.messages : [],
82 older: history.ok ? history.value.older : null,
83 joined,
84 };
85}
86
87/** Opening a thread (`?thread=`) is the same page: nothing to load again. */
88export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
89 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
90 if (currentUrl.pathname === nextUrl.pathname) return false;
91 return defaultShouldRevalidate;
92}
93
94export default function ChannelPage({ loaderData }: Route.ComponentProps) {
95 if (loaderData.unavailable) return <ChatUnavailable title={loaderData.title} />;
96 return <ChannelView key={loaderData.channel.id} data={loaderData} />;
97}