Skip to content
138 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 // The workspace's own emoji, for the picker and for drawing :name: (components/emoji).
26 if (url.searchParams.get("emoji")) return chat.listEmoji(slug, viewer);
27 if (!channel) return chat.sidebar(slug, viewer);
28 return chat.messages(slug, channel, viewer, {
29 thread_root: url.searchParams.get("thread"),
30 before: url.searchParams.get("before"),
31 after: url.searchParams.get("after"),
32 limit: Number(url.searchParams.get("limit")) || 60,
33 });
34 });
35 return Response.json(answer, { headers: { "cache-control": "no-store" } });
36}
37
38type Sent = {
39 intent?: string;
40 channel_id?: string;
41 body?: string;
42 thread_root?: string | null;
43 id?: string;
44 starred?: boolean;
45 muted?: boolean;
46 name?: string;
47 topic?: string;
48 private?: boolean;
49 members?: string[];
50 member?: string;
51 message_id?: string;
52 emoji?: string;
53 archived?: boolean;
54};
55
56export async function action({ params, context, request }: Route.ActionArgs) {
57 assertSameOrigin(request);
58 const viewer = requireUser(context, request);
59 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
60 const slug = params.owner.toLowerCase();
61 const sent = (await request.json().catch(() => ({}))) as Sent;
62 const channel = sent.channel_id ?? "";
63 const answer = await safely(async (): Promise<Result<unknown>> => {
64 switch (sent.intent) {
65 case "post": {
66 const body = (sent.body ?? "").trim();
67 if (!body) return { ok: false, error: { code: "invalid", message: "Write something to send." } };
68 return chat.post(slug, channel, viewer, { body, thread_root: sent.thread_root ?? null });
69 }
70 case "edit":
71 return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim());
72 case "remove":
73 return chat.remove(slug, channel, viewer, sent.id ?? "");
74 case "read":
75 return chat.markRead(slug, channel, viewer, sent.id ?? "");
76 case "react":
77 return chat.react(slug, channel, viewer, sent.message_id ?? "", sent.emoji ?? "");
78 case "unreact":
79 return chat.unreact(slug, channel, viewer, sent.message_id ?? "", sent.emoji ?? "");
80 case "preferences":
81 return chat.setPreferences(slug, channel, viewer, { starred: sent.starred, muted: sent.muted });
82 case "join":
83 return chat.join(slug, channel, viewer);
84 case "leave":
85 return chat.leave(slug, channel, viewer);
86 case "invite": {
87 const [member] = await principalsFrom(slug, viewer, [sent.member ?? ""]);
88 const principal = member ?? parsePrincipalKey(sent.member ?? "");
89 if (!principal) return { ok: false, error: { code: "invalid", message: "Choose someone to add." } };
90 return chat.invite(slug, channel, viewer, principal);
91 }
92 case "create_channel": {
93 const name = channelName(sent.name ?? "");
94 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
95 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
96 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
97 }
98 const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) });
99 return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
100 }
101 case "update_channel": {
102 // A rename, a new topic, archiving or unarchiving; the chat service checks who may.
103 const change: { name?: string; topic?: string | null; archived?: boolean } = {};
104 if (typeof sent.name === "string") {
105 const name = channelName(sent.name);
106 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
107 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
108 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
109 }
110 change.name = name;
111 }
112 if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null;
113 if (typeof sent.archived === "boolean") change.archived = sent.archived;
114 const updated = await chat.updateChannel(slug, channel, viewer, change);
115 return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated;
116 }
117 case "dm": {
118 const members = await principalsFrom(slug, viewer, sent.members ?? []);
119 if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } };
120 const opened = await chat.openDm(slug, viewer, members);
121 return opened.ok ? { ok: true, value: { channel: opened.value, to: channelPath(slug, opened.value) } } : opened;
122 }
123 default:
124 return { ok: false, error: { code: "invalid", message: "Unknown request." } };
125 }
126 });
127 return Response.json(answer, { headers: { "cache-control": "no-store" } });
128}
129
130/** A service's answer, or a failure that says chat did not answer. */
131async function safely<T>(call: () => Promise<Result<T>>): Promise<Result<T>> {
132 try {
133 return await call();
134 } catch (error) {
135 console.error("chat: the chat service did not answer", error);
136 return { ok: false, error: { code: "conflict", message: "Chat didn't answer. Try again in a moment." } };
137 }
138}