Skip to content
257 linesCodeBlameRaw
1import { Hash } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, data, useNavigation } from "react-router";
4
5import type { ChannelManagers, ChatAllowed, ChatSettings, EmojiUpload } from "@g1t/contracts";
6
7import type { Route } from "./+types/chat-settings";
8import { ErrorText } from "../../components/ui";
9import { Button } from "../../components/ui/button";
10import { Card } from "../../components/ui/card";
11import { CheckboxOption } from "../../components/ui/checkbox";
12import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
13import { page } from "../../lib/meta";
14import { chat } from "../../lib/services.server";
15import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Chat settings · ${params.owner} · g1t` });
19}
20
21/**
22 * Settings, Chat: what members may do in the workspace's chat. Every
23 * member can read it, so nobody has to guess why a button is off; only
24 * owners change it. The chat service enforces each setting.
25 */
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const viewer = requireUser(context, request);
28 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30 const view = await chat.chatSettings(slug, viewer).catch(() => null);
31 if (!view?.ok) return { slug, view: null, error: view ? view.error.message : "Chat didn't answer. Try again in a moment." };
32 return { slug, view: view.value, error: null };
33}
34
35export async function action({ params, context, request }: Route.ActionArgs) {
36 assertSameOrigin(request);
37 const viewer = requireUser(context, request);
38 const slug = params.owner.toLowerCase();
39 const form = await request.formData();
40 const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members");
41 const change: Partial<ChatSettings> = {
42 public_channels: who("public_channels"),
43 private_channels: who("private_channels"),
44 manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers,
45 emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload,
46 default_channels: form.getAll("default_channels").map(String),
47 };
48 try {
49 const saved = await chat.setChatSettings(slug, viewer, change);
50 return saved.ok ? { ok: true as const, settings: saved.value } : { ok: false as const, error: saved.error.message };
51 } catch (error) {
52 console.error("chat settings: the chat service did not answer", error);
53 return { ok: false as const, error: "Chat didn't answer. Try again in a moment." };
54 }
55}
56
57const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0";
58
59export default function ChatSettingsPage({ loaderData, actionData }: Route.ComponentProps) {
60 const { slug, view } = loaderData;
61 const navigation = useNavigation();
62 const saving = navigation.state !== "idle" && navigation.formMethod === "POST";
63 const [settings, setSettings] = useState<ChatSettings | null>(view?.settings ?? null);
64 // What the service saved, once it answers.
65 useEffect(() => {
66 if (actionData?.ok) setSettings(actionData.settings);
67 }, [actionData]);
68 if (!view || !settings) {
69 return (
70 <div className="max-w-2xl">
71 <Header />
72 <ErrorText>{loaderData.error}</ErrorText>
73 </div>
74 );
75 }
76 const owner = view.can.manage_settings;
77 const saved = actionData?.ok ? actionData.settings : view.settings;
78 const changed = JSON.stringify(settings) !== JSON.stringify(saved);
79 const set = <K extends keyof ChatSettings>(key: K, value: ChatSettings[K]) => setSettings((now) => (now ? { ...now, [key]: value } : now));
80 const defaults = new Set(settings.default_channels);
81 return (
82 <div className="max-w-2xl">
83 <Header />
84 {!owner && (
85 <Card asChild radius="lg" className="mb-8 px-4 py-3 text-sm text-muted">
86 <p>
87 Only workspace owners can change these. They are shown so you know what members can do here.
88 </p>
89 </Card>
90 )}
91 <Form method="post" className="space-y-8">
92 <fieldset disabled={!owner} className="space-y-8">
93 <section className={SECTION}>
94 <h2 className="font-medium">Creating channels</h2>
95 <p className="mt-1.5 text-xs text-faint">Who can create channels. Whoever creates one becomes its first owner.</p>
96 <div className="mt-5 grid gap-6 sm:grid-cols-2">
97 <Choice
98 name="public_channels"
99 title="Public channels"
100 value={settings.public_channels}
101 onChange={(value) => set("public_channels", value as ChatAllowed)}
102 options={[
103 { value: "members", label: "Any member", description: "The default." },
104 { value: "owners", label: "Owners only", description: "Members join and read public channels, but ask an owner for a new one." },
105 ]}
106 disabled={!owner}
107 />
108 <Choice
109 name="private_channels"
110 title="Private channels"
111 value={settings.private_channels}
112 onChange={(value) => set("private_channels", value as ChatAllowed)}
113 options={[
114 { value: "members", label: "Any member", description: "The default." },
115 { value: "owners", label: "Owners only", description: "Only owners can make a channel that is kept to the people invited." },
116 ]}
117 disabled={!owner}
118 />
119 </div>
120 </section>
121
122 <section className={SECTION}>
123 <h2 className="font-medium">Renaming and archiving channels</h2>
124 <p className="mt-1.5 text-xs text-faint">
125 Who can rename a channel, archive it, and bring it back. Any member of a channel can change its topic. #general is never renamed or
126 archived.
127 </p>
128 <div className="mt-5">
129 <Choice
130 name="manage_channels"
131 title="Who can rename and archive"
132 hideTitle
133 value={settings.manage_channels}
134 onChange={(value) => set("manage_channels", value as ChannelManagers)}
135 options={[
136 { value: "channel_owners", label: "Channel owners and workspace owners", description: "Whoever created a channel looks after it. The default." },
137 { value: "owners", label: "Workspace owners only" },
138 ]}
139 disabled={!owner}
140 />
141 </div>
142 </section>
143
144 <section className={SECTION}>
145 <h2 className="font-medium">Custom emoji</h2>
146 <p className="mt-1.5 text-xs text-faint">
147 Who can add the workspace&apos;s own emoji and aliases. Whoever added one, and owners, can remove it.{" "}
148 <Link to={`/${slug}/-/emoji`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
149 Manage emoji
150 </Link>
151 </p>
152 <div className="mt-5">
153 <Choice
154 name="emoji_upload"
155 title="Who can add emoji"
156 hideTitle
157 value={settings.emoji_upload}
158 onChange={(value) => set("emoji_upload", value as EmojiUpload)}
159 options={[
160 { value: "members", label: "Any member", description: "The default." },
161 { value: "admins", label: "Owners only", description: "Members use the workspace's emoji; only owners add them." },
162 ]}
163 disabled={!owner}
164 />
165 </div>
166 </section>
167
168 <section className={SECTION}>
169 <h2 className="font-medium">Default channels</h2>
170 <p className="mt-1.5 text-xs text-faint">
171 The public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not
172 moved.
173 </p>
174 {view.channels.length === 0 ? (
175 <p className="mt-5 text-sm text-muted">No public channels yet.</p>
176 ) : (
177 <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
178 {view.channels.map((channel) => (
179 <li key={channel.id}>
180 {defaults.has(channel.id) && <input type="hidden" name="default_channels" value={channel.id} />}
181 <CheckboxOption
182 checked={defaults.has(channel.id)}
183 disabled={!owner}
184 onCheckedChange={(on) =>
185 set("default_channels", on === true ? [...settings.default_channels, channel.id] : settings.default_channels.filter((id) => id !== channel.id))
186 }
187 label={
188 <span className="inline-flex items-center gap-1">
189 <Hash size={13} className="text-faint" />
190 {channel.name}
191 </span>
192 }
193 description={channel.topic ?? undefined}
194 />
195 </li>
196 ))}
197 </ul>
198 )}
199 </section>
200 </fieldset>
201
202 {owner && (
203 <div className="flex items-center gap-3 border-t border-line pt-6">
204 <Button type="submit" variant="accent" disabled={saving || !changed}>
205 {saving ? "Saving…" : "Save"}
206 </Button>
207 {actionData && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>}
208 {actionData?.ok && !changed && (
209 <p role="status" className="text-xs text-muted">
210 Saved.
211 </p>
212 )}
213 </div>
214 )}
215 </Form>
216 </div>
217 );
218}
219
220function Header() {
221 return (
222 <header className="mb-8 border-b border-line pb-6">
223 <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
224 <p className="mt-1.5 text-sm text-muted">What members can do in the workspace&apos;s chat: channels, emoji, and where someone new starts.</p>
225 </header>
226 );
227}
228
229/** One setting's choices, posted as `name`. */
230function Choice({
231 name,
232 title,
233 hideTitle,
234 value,
235 onChange,
236 options,
237 disabled,
238}: {
239 name: string;
240 title: string;
241 hideTitle?: boolean;
242 value: string;
243 onChange: (value: string) => void;
244 options: { value: string; label: string; description?: string }[];
245 disabled?: boolean;
246}) {
247 return (
248 <div>
249 {!hideTitle && <h3 className="mb-2.5 text-sm font-medium text-fg-soft">{title}</h3>}
250 <RadioGroup name={name} value={value} onValueChange={onChange} aria-label={title} disabled={disabled}>
251 {options.map((option) => (
252 <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled={disabled} />
253 ))}
254 </RadioGroup>
255 </div>
256 );
257}