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