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.

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