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.

138 linesCodeBlameRaw
1import { useEffect } from "react";
2import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router";
3
4
5import type { Channel, ChannelMember, ChatMessage, User } from "@g1t/contracts";
6
7import type { Route } from "./+types/channel";
8import { ChannelView } from "../../../components/chat/channel";
9import { conversationCache } from "../../../components/chat/conversation-cache";
10import { ChatUnavailable } from "../../../components/chat/empty";
11import { channelPath } from "../../../lib/chat";
12import { rememberChat } from "../../../lib/chat.server";
13import { page } from "../../../lib/meta";
14import { chat } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16
17export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
18 const title = loaderData && "title" in loaderData ? loaderData.title : "Chat";
19 return page(args, { title: `${title} · ${params.owner} · g1t` });
20}
21
22export type ChannelData =
23 | { unavailable: true; title: string }
24 | {
25 unavailable: false;
26 title: string;
27 channel: Channel;
28 members: ChannelMember[];
29 messages: ChatMessage[];
30 older: string | null;
31 /** Whether the viewer is in it: a public channel can be read before joining. */
32 joined: boolean;
33 /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */
34 can_manage?: boolean;
35 };
36
37/**
38 * One conversation: a channel by its name (`-/chat/general`) or a direct
39 * message by its id (`-/chat/dm/<id>`), with its latest messages. Live
40 * updates arrive over the socket once the page is open.
41 */
42export async function loader({ params, context, request }: Route.LoaderArgs) {
43 const viewer = requireUser(context, request);
44 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
45 const slug = params.owner.toLowerCase();
46 const name = "channel" in params ? (params.channel as string | undefined) : undefined;
47 // `-/chat/dm/g1t`: the viewer's direct message with @g1t, opened on first use.
48 if ("id" in params && params.id === "g1t") {
49 const opened = await chat.g1tDm(slug, viewer).catch(() => null);
50 if (opened?.ok) throw redirect(channelPath(slug, opened.value));
51 }
52 const fallbackTitle = name ? `#${name}` : "Direct message";
53 const read = await readConversation(slug, viewer, name, "id" in params ? (params.id as string | undefined) : undefined).catch(
54 (error: unknown) => {
55 console.error("chat: the conversation could not be read", error);
56 return "unavailable" as const;
57 },
58 );
59 if (read === "missing") throw data(null, { status: 404 });
60 if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
61 const secure = new URL(request.url).protocol === "https:";
62 return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
63}
64
65/** The conversation and its latest messages; "missing" for a 404, "unavailable" when chat does not answer. */
66async function readConversation(
67 slug: string,
68 viewer: User,
69 name: string | undefined,
70 byId: string | undefined,
71): Promise<Extract<ChannelData, { unavailable: false }> | "missing" | "unavailable"> {
72 // A channel's address is its name; a direct message's, its id.
73 const lookup = byId ? chat.channel(slug, byId, viewer) : name ? chat.channelByName(slug, name, viewer) : null;
74 if (!lookup) return "missing";
75 const detail = await lookup;
76 if (!detail.ok) return detail.error.code === "not_found" || detail.error.code === "forbidden" ? "missing" : "unavailable";
77 const history = await chat.messages(slug, detail.value.channel.id, viewer, { limit: 60 });
78 const { channel, members } = detail.value;
79 // A public channel can be read before joining it.
80 const joined = members.some((m) => m.member.kind === "user" && m.member.id === viewer.id);
81 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === viewer.id));
82 const title =
83 channel.kind === "channel"
84 ? `#${channel.name}`
85 : others.map((m) => m.member.display_name || m.member.name).join(", ") || "Direct message";
86 return {
87 unavailable: false,
88 title,
89 channel,
90 members,
91 messages: history.ok ? history.value.messages : [],
92 older: history.ok ? history.value.older : null,
93 joined,
94 can_manage: detail.value.can_manage === true,
95 };
96}
97
98/** Opening a thread (`?thread=`) is the same page: nothing to load again. */
99export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
100 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
101 if (currentUrl.pathname === nextUrl.pathname) return false;
102 return defaultShouldRevalidate;
103}
104
105/** Conversations already seen in this tab (lib/chat-cache.ts), kept by the page as it changes. */
106const cache = conversationCache;
107
108/**
109 * Switching conversations: one seen before draws at once from the cache,
110 * and the server is asked again behind it; what it answers is merged in by
111 * id (components/chat/channel.tsx listens for `g1t:chat-fresh`). One not
112 * seen waits for the server, and is kept for next time.
113 */
114export async function clientLoader({ request, serverLoader }: Route.ClientLoaderArgs) {
115 const key = new URL(request.url).pathname;
116 const kept = cache.peek(key) ?? (await cache.load(key));
117 const fresh = serverLoader().then((value) => {
118 if (!value.unavailable) {
119 cache.set(key, value);
120 window.dispatchEvent(new CustomEvent("g1t:chat-fresh", { detail: { key, value } }));
121 }
122 return value;
123 });
124 if (kept && !kept.unavailable) {
125 fresh.catch(() => undefined);
126 return kept;
127 }
128 return fresh;
129}
130
131export default function ChannelPage({ loaderData }: Route.ComponentProps) {
132 // What the server drew first is kept too, for coming back.
133 useEffect(() => {
134 if (!loaderData.unavailable) cache.set(window.location.pathname, loaderData);
135 }, [loaderData]);
136 if (loaderData.unavailable) return <ChatUnavailable title={loaderData.title} />;
137 return <ChannelView key={loaderData.channel.id} data={loaderData} />;
138}