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.

174 linesCodeBlameRaw
1import { useEffect } from "react";
2import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router";
3
4
5import type { Channel, ChannelAccessRequest, ChannelDetail, ChannelInvitation, 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, RequestAccess } from "../../../components/chat/empty";
11import { channelPath } from "../../../lib/chat";
12import { rememberChat } from "../../../lib/chat.server";
13import { page } from "../../../lib/meta";
14import { chat, identity } 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 | {
24 unavailable: true;
25 title: string;
26 /**
27 * A channel by name the viewer cannot open: private, kept to a
28 * workspace they are not in, or not there at all (which looks the
29 * same). They may ask to be let in.
30 */
31 locked?: { name: string; workspace: { slug: string; name: string }; member: boolean };
32 }
33 | {
34 unavailable: false;
35 title: string;
36 channel: Channel;
37 members: ChannelMember[];
38 messages: ChatMessage[];
39 older: string | null;
40 /** Whether the viewer is in it: a public channel can be read before joining. */
41 joined: boolean;
42 /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */
43 can_manage?: boolean;
44 /** What else the viewer may do there, and who is asked in or asks to come in (`ChannelDetail`). */
45 can_make_public?: boolean;
46 can_invite_guests?: boolean;
47 can_remove_members?: boolean;
48 /** The viewer is not a member of the workspace: a guest here. */
49 guest?: boolean;
50 workspace?: ChannelDetail["workspace"];
51 invited?: ChannelInvitation[];
52 requests?: ChannelAccessRequest[];
53 };
54
55/**
56 * One conversation: a channel by its name (`-/chat/general`) or a direct
57 * message by its id (`-/chat/dm/<id>`), with its latest messages. Live
58 * updates arrive over the socket once the page is open.
59 */
60export async function loader({ params, context, request }: Route.LoaderArgs) {
61 const viewer = requireUser(context, request);
62 // Members and guests alike: the chat service answers with what the viewer may read.
63 const member = roleIn(viewer, params.owner) != null;
64 const slug = params.owner.toLowerCase();
65 const name = "channel" in params ? (params.channel as string | undefined) : undefined;
66 // `-/chat/dm/g1t`: the viewer's direct message with @g1t, opened on first use; members only.
67 if (!member && "id" in params) throw data(null, { status: 404 });
68 if ("id" in params && params.id === "g1t") {
69 const opened = await chat.g1tDm(slug, viewer).catch(() => null);
70 if (opened?.ok) throw redirect(channelPath(slug, opened.value));
71 }
72 const fallbackTitle = name ? `#${name}` : "Direct message";
73 const read = await readConversation(slug, viewer, name, "id" in params ? (params.id as string | undefined) : undefined).catch(
74 (error: unknown) => {
75 console.error("chat: the conversation could not be read", error);
76 return "unavailable" as const;
77 },
78 );
79 if (read === "missing") {
80 // A channel's link that does not open: offer to ask to be let in, the same whether or not it is there.
81 if (!name) throw data(null, { status: 404 });
82 const workspace = await identity.getWorkspace(slug).catch(() => null);
83 if (!workspace) throw data(null, { status: 404 });
84 return data(
85 { unavailable: true, title: `#${name}`, locked: { name, workspace: { slug: workspace.slug.toLowerCase(), name: workspace.name }, member } } satisfies ChannelData,
86 { status: 403 },
87 );
88 }
89 if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
90 const secure = new URL(request.url).protocol === "https:";
91 return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
92}
93
94/** The conversation and its latest messages; "missing" for a 404, "unavailable" when chat does not answer. */
95async function readConversation(
96 slug: string,
97 viewer: User,
98 name: string | undefined,
99 byId: string | undefined,
100): Promise<Extract<ChannelData, { unavailable: false }> | "missing" | "unavailable"> {
101 // A channel's address is its name; a direct message's, its id.
102 const lookup = byId ? chat.channel(slug, byId, viewer) : name ? chat.channelByName(slug, name, viewer) : null;
103 if (!lookup) return "missing";
104 const detail = await lookup;
105 if (!detail.ok) return detail.error.code === "not_found" || detail.error.code === "forbidden" ? "missing" : "unavailable";
106 const history = await chat.messages(slug, detail.value.channel.id, viewer, { limit: 60 });
107 const { channel, members } = detail.value;
108 // A public channel can be read before joining it.
109 const joined = members.some((m) => m.member.kind === "user" && m.member.id === viewer.id);
110 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === viewer.id));
111 const title =
112 channel.kind === "channel"
113 ? `#${channel.name}`
114 : others.map((m) => m.member.display_name || m.member.name).join(", ") || "Direct message";
115 return {
116 unavailable: false,
117 title,
118 channel,
119 members,
120 messages: history.ok ? history.value.messages : [],
121 older: history.ok ? history.value.older : null,
122 joined,
123 can_manage: detail.value.can_manage === true,
124 can_make_public: detail.value.can_make_public === true,
125 can_invite_guests: detail.value.can_invite_guests === true,
126 can_remove_members: detail.value.can_remove_members === true,
127 guest: detail.value.guest === true,
128 workspace: detail.value.workspace,
129 invited: detail.value.invited ?? [],
130 requests: detail.value.requests ?? [],
131 };
132}
133
134/** Opening a thread (`?thread=`) is the same page: nothing to load again. */
135export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
136 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
137 if (currentUrl.pathname === nextUrl.pathname) return false;
138 return defaultShouldRevalidate;
139}
140
141/** Conversations already seen in this tab (lib/chat-cache.ts), kept by the page as it changes. */
142const cache = conversationCache;
143
144/**
145 * Switching conversations: one seen before draws at once from the cache,
146 * and the server is asked again behind it; what it answers is merged in by
147 * id (components/chat/channel.tsx listens for `g1t:chat-fresh`). One not
148 * seen waits for the server, and is kept for next time.
149 */
150export async function clientLoader({ request, serverLoader }: Route.ClientLoaderArgs) {
151 const key = new URL(request.url).pathname;
152 const kept = cache.peek(key) ?? (await cache.load(key));
153 const fresh = serverLoader().then((value) => {
154 if (!value.unavailable) {
155 cache.set(key, value);
156 window.dispatchEvent(new CustomEvent("g1t:chat-fresh", { detail: { key, value } }));
157 }
158 return value;
159 });
160 if (kept && !kept.unavailable) {
161 fresh.catch(() => undefined);
162 return kept;
163 }
164 return fresh;
165}
166
167export default function ChannelPage({ loaderData }: Route.ComponentProps) {
168 // What the server drew first is kept too, for coming back.
169 useEffect(() => {
170 if (!loaderData.unavailable) cache.set(window.location.pathname, loaderData);
171 }, [loaderData]);
172 if (loaderData.unavailable) return "locked" in loaderData && loaderData.locked ? <RequestAccess {...loaderData.locked} /> : <ChatUnavailable title={loaderData.title} />;
173 return <ChannelView key={loaderData.channel.id} data={loaderData} />;
174}