Skip to content
133 linesCodeBlameRaw
1import { useEffect } from "react";
2import { type ShouldRevalidateFunctionArgs, data } 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 const fallbackTitle = name ? `#${name}` : "Direct message";
48 const read = await readConversation(slug, viewer, name, "id" in params ? (params.id as string | undefined) : undefined).catch(
49 (error: unknown) => {
50 console.error("chat: the conversation could not be read", error);
51 return "unavailable" as const;
52 },
53 );
54 if (read === "missing") throw data(null, { status: 404 });
55 if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
56 const secure = new URL(request.url).protocol === "https:";
57 return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
58}
59
60/** The conversation and its latest messages; "missing" for a 404, "unavailable" when chat does not answer. */
61async function readConversation(
62 slug: string,
63 viewer: User,
64 name: string | undefined,
65 byId: string | undefined,
66): Promise<Extract<ChannelData, { unavailable: false }> | "missing" | "unavailable"> {
67 // A channel's address is its name; a direct message's, its id.
68 const lookup = byId ? chat.channel(slug, byId, viewer) : name ? chat.channelByName(slug, name, viewer) : null;
69 if (!lookup) return "missing";
70 const detail = await lookup;
71 if (!detail.ok) return detail.error.code === "not_found" || detail.error.code === "forbidden" ? "missing" : "unavailable";
72 const history = await chat.messages(slug, detail.value.channel.id, viewer, { limit: 60 });
73 const { channel, members } = detail.value;
74 // A public channel can be read before joining it.
75 const joined = members.some((m) => m.member.kind === "user" && m.member.id === viewer.id);
76 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === viewer.id));
77 const title =
78 channel.kind === "channel"
79 ? `#${channel.name}`
80 : others.map((m) => m.member.display_name || m.member.name).join(", ") || "Direct message";
81 return {
82 unavailable: false,
83 title,
84 channel,
85 members,
86 messages: history.ok ? history.value.messages : [],
87 older: history.ok ? history.value.older : null,
88 joined,
89 can_manage: detail.value.can_manage === true,
90 };
91}
92
93/** Opening a thread (`?thread=`) is the same page: nothing to load again. */
94export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
95 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
96 if (currentUrl.pathname === nextUrl.pathname) return false;
97 return defaultShouldRevalidate;
98}
99
100/** Conversations already seen in this tab (lib/chat-cache.ts), kept by the page as it changes. */
101const cache = conversationCache;
102
103/**
104 * Switching conversations: one seen before draws at once from the cache,
105 * and the server is asked again behind it; what it answers is merged in by
106 * id (components/chat/channel.tsx listens for `g1t:chat-fresh`). One not
107 * seen waits for the server, and is kept for next time.
108 */
109export async function clientLoader({ request, serverLoader }: Route.ClientLoaderArgs) {
110 const key = new URL(request.url).pathname;
111 const kept = cache.peek(key) ?? (await cache.load(key));
112 const fresh = serverLoader().then((value) => {
113 if (!value.unavailable) {
114 cache.set(key, value);
115 window.dispatchEvent(new CustomEvent("g1t:chat-fresh", { detail: { key, value } }));
116 }
117 return value;
118 });
119 if (kept && !kept.unavailable) {
120 fresh.catch(() => undefined);
121 return kept;
122 }
123 return fresh;
124}
125
126export default function ChannelPage({ loaderData }: Route.ComponentProps) {
127 // What the server drew first is kept too, for coming back.
128 useEffect(() => {
129 if (!loaderData.unavailable) cache.set(window.location.pathname, loaderData);
130 }, [loaderData]);
131 if (loaderData.unavailable) return <ChatUnavailable title={loaderData.title} />;
132 return <ChannelView key={loaderData.channel.id} data={loaderData} />;
133}