Skip to content
113 linesCodeBlameRaw
1import { data } from "react-router";
2
3import { type Result, parsePrincipalKey } from "@g1t/contracts";
4
5import type { Route } from "./+types/api";
6import { RESERVED_CHANNEL_NAMES, channelName, channelPath } from "../../../lib/chat";
7import { principalsFrom } from "../../../lib/chat.server";
8import { chat } from "../../../lib/services.server";
9import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
10
11/**
12 * What a chat page asks for as it runs, as JSON, without loading a page:
13 * the sidebar again, a thread's replies, older messages; and everything it
14 * sends: messages, read marks, stars, new channels and direct messages.
15 * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services'
16 * own shape.
17 */
18export async function loader({ params, context, request }: Route.LoaderArgs) {
19 const viewer = requireUser(context, request);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const slug = params.owner.toLowerCase();
22 const url = new URL(request.url);
23 const channel = url.searchParams.get("channel");
24 const answer = await safely<unknown>(() => {
25 if (!channel) return chat.sidebar(slug, viewer);
26 return chat.messages(slug, channel, viewer, {
27 thread_root: url.searchParams.get("thread"),
28 before: url.searchParams.get("before"),
29 after: url.searchParams.get("after"),
30 limit: Number(url.searchParams.get("limit")) || 60,
31 });
32 });
33 return Response.json(answer, { headers: { "cache-control": "no-store" } });
34}
35
36type Sent = {
37 intent?: string;
38 channel_id?: string;
39 body?: string;
40 thread_root?: string | null;
41 id?: string;
42 starred?: boolean;
43 muted?: boolean;
44 name?: string;
45 topic?: string;
46 private?: boolean;
47 members?: string[];
48 member?: string;
49};
50
51export async function action({ params, context, request }: Route.ActionArgs) {
52 assertSameOrigin(request);
53 const viewer = requireUser(context, request);
54 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
55 const slug = params.owner.toLowerCase();
56 const sent = (await request.json().catch(() => ({}))) as Sent;
57 const channel = sent.channel_id ?? "";
58 const answer = await safely(async (): Promise<Result<unknown>> => {
59 switch (sent.intent) {
60 case "post": {
61 const body = (sent.body ?? "").trim();
62 if (!body) return { ok: false, error: { code: "invalid", message: "Write something to send." } };
63 return chat.post(slug, channel, viewer, { body, thread_root: sent.thread_root ?? null });
64 }
65 case "edit":
66 return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim());
67 case "remove":
68 return chat.remove(slug, channel, viewer, sent.id ?? "");
69 case "read":
70 return chat.markRead(slug, channel, viewer, sent.id ?? "");
71 case "preferences":
72 return chat.setPreferences(slug, channel, viewer, { starred: sent.starred, muted: sent.muted });
73 case "join":
74 return chat.join(slug, channel, viewer);
75 case "leave":
76 return chat.leave(slug, channel, viewer);
77 case "invite": {
78 const [member] = await principalsFrom(slug, viewer, [sent.member ?? ""]);
79 const principal = member ?? parsePrincipalKey(sent.member ?? "");
80 if (!principal) return { ok: false, error: { code: "invalid", message: "Choose someone to add." } };
81 return chat.invite(slug, channel, viewer, principal);
82 }
83 case "create_channel": {
84 const name = channelName(sent.name ?? "");
85 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
86 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
87 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
88 }
89 const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) });
90 return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
91 }
92 case "dm": {
93 const members = await principalsFrom(slug, viewer, sent.members ?? []);
94 if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } };
95 const opened = await chat.openDm(slug, viewer, members);
96 return opened.ok ? { ok: true, value: { channel: opened.value, to: channelPath(slug, opened.value) } } : opened;
97 }
98 default:
99 return { ok: false, error: { code: "invalid", message: "Unknown request." } };
100 }
101 });
102 return Response.json(answer, { headers: { "cache-control": "no-store" } });
103}
104
105/** A service's answer, or a failure that says chat did not answer. */
106async function safely<T>(call: () => Promise<Result<T>>): Promise<Result<T>> {
107 try {
108 return await call();
109 } catch (error) {
110 console.error("chat: the chat service did not answer", error);
111 return { ok: false, error: { code: "conflict", message: "Chat didn't answer. Try again in a moment." } };
112 }
113}