Skip to content
245 linesCodeBlameRaw
1import { Hash } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, data, useNavigation } from "react-router";
4
5import type { ChannelManagers, 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 // Who creates channels is set under Permissions; this page leaves it as it is.
41 const change: Partial<ChatSettings> = {
42 manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers,
43 emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload,
44 default_channels: form.getAll("default_channels").map(String),
45 };
46 try {
47 const saved = await chat.setChatSettings(slug, viewer, change);
48 return saved.ok ? { ok: true as const, settings: saved.value } : { ok: false as const, error: saved.error.message };
49 } catch (error) {
50 console.error("chat settings: the chat service did not answer", error);
51 return { ok: false as const, error: "Chat didn't answer. Try again in a moment." };
52 }
53}
54
55const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0";
56
57export default function ChatSettingsPage({ loaderData, actionData }: Route.ComponentProps) {
58 const { slug, view } = loaderData;
59 const navigation = useNavigation();
60 const saving = navigation.state !== "idle" && navigation.formMethod === "POST";
61 const [settings, setSettings] = useState<ChatSettings | null>(view?.settings ?? null);
62 // What the service saved, once it answers.
63 useEffect(() => {
64 if (actionData?.ok) setSettings(actionData.settings);
65 }, [actionData]);
66 if (!view || !settings) {
67 return (
68 <div className="max-w-2xl">
69 <Header />
70 <ErrorText>{loaderData.error}</ErrorText>
71 </div>
72 );
73 }
74 const owner = view.can.manage_settings;
75 const saved = actionData?.ok ? actionData.settings : view.settings;
76 const changed = JSON.stringify(settings) !== JSON.stringify(saved);
77 const set = <K extends keyof ChatSettings>(key: K, value: ChatSettings[K]) => setSettings((now) => (now ? { ...now, [key]: value } : now));
78 const defaults = new Set(settings.default_channels);
79 return (
80 <div className="max-w-2xl">
81 <Header />
82 {!owner && (
83 <Card asChild radius="lg" className="mb-8 px-4 py-3 text-sm text-muted">
84 <p>
85 Only workspace owners can change these. They are shown so you know what members can do here.
86 </p>
87 </Card>
88 )}
89 <Form method="post" className="space-y-8">
90 <fieldset disabled={!owner} className="space-y-8">
91 <section className={SECTION}>
92 <h2 className="font-medium">Creating channels</h2>
93 <p className="mt-1.5 text-xs text-faint">
94 Today {settings.public_channels === "owners" ? "only owners" : "any member"} can create a public channel and{" "}
95 {settings.private_channels === "owners" ? "only owners" : "any member"} a private one.{" "}
96 {owner ? (
97 <>
98 Change it under{" "}
99 <Link to={`/${slug}/-/permissions`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
100 Permissions
101 </Link>
102 , with everything else about who may do what.
103 </>
104 ) : (
105 "Owners change it under Permissions."
106 )}
107 </p>
108 </section>
109
110 <section className={SECTION}>
111 <h2 className="font-medium">Renaming and archiving channels</h2>
112 <p className="mt-1.5 text-xs text-faint">
113 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
114 archived.
115 </p>
116 <div className="mt-5">
117 <Choice
118 name="manage_channels"
119 title="Who can rename and archive"
120 hideTitle
121 value={settings.manage_channels}
122 onChange={(value) => set("manage_channels", value as ChannelManagers)}
123 options={[
124 { value: "channel_owners", label: "Channel owners and workspace owners", description: "Whoever created a channel looks after it. The default." },
125 { value: "owners", label: "Workspace owners only" },
126 ]}
127 disabled={!owner}
128 />
129 </div>
130 </section>
131
132 <section className={SECTION}>
133 <h2 className="font-medium">Custom emoji</h2>
134 <p className="mt-1.5 text-xs text-faint">
135 Who can add the workspace&apos;s own emoji and aliases. Whoever added one, and owners, can remove it.{" "}
136 <Link to={`/${slug}/-/emoji`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
137 Manage emoji
138 </Link>
139 </p>
140 <div className="mt-5">
141 <Choice
142 name="emoji_upload"
143 title="Who can add emoji"
144 hideTitle
145 value={settings.emoji_upload}
146 onChange={(value) => set("emoji_upload", value as EmojiUpload)}
147 options={[
148 { value: "members", label: "Any member", description: "The default." },
149 { value: "admins", label: "Owners only", description: "Members use the workspace's emoji; only owners add them." },
150 ]}
151 disabled={!owner}
152 />
153 </div>
154 </section>
155
156 <section className={SECTION}>
157 <h2 className="font-medium">Default channels</h2>
158 <p className="mt-1.5 text-xs text-faint">
159 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
160 moved.
161 </p>
162 {view.channels.length === 0 ? (
163 <p className="mt-5 text-sm text-muted">No public channels yet.</p>
164 ) : (
165 <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
166 {view.channels.map((channel) => (
167 <li key={channel.id}>
168 {defaults.has(channel.id) && <input type="hidden" name="default_channels" value={channel.id} />}
169 <CheckboxOption
170 checked={defaults.has(channel.id)}
171 disabled={!owner}
172 onCheckedChange={(on) =>
173 set("default_channels", on === true ? [...settings.default_channels, channel.id] : settings.default_channels.filter((id) => id !== channel.id))
174 }
175 label={
176 <span className="inline-flex items-center gap-1">
177 <Hash size={13} className="text-faint" />
178 {channel.name}
179 </span>
180 }
181 description={channel.topic ?? undefined}
182 />
183 </li>
184 ))}
185 </ul>
186 )}
187 </section>
188 </fieldset>
189
190 {owner && (
191 <div className="flex items-center gap-3 border-t border-line pt-6">
192 <Button type="submit" variant="accent" disabled={saving || !changed}>
193 {saving ? "Saving…" : "Save"}
194 </Button>
195 {actionData && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>}
196 {actionData?.ok && !changed && (
197 <p role="status" className="text-xs text-muted">
198 Saved.
199 </p>
200 )}
201 </div>
202 )}
203 </Form>
204 </div>
205 );
206}
207
208function Header() {
209 return (
210 <header className="mb-8 border-b border-line pb-6">
211 <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
212 <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>
213 </header>
214 );
215}
216
217/** One setting's choices, posted as `name`. */
218function Choice({
219 name,
220 title,
221 hideTitle,
222 value,
223 onChange,
224 options,
225 disabled,
226}: {
227 name: string;
228 title: string;
229 hideTitle?: boolean;
230 value: string;
231 onChange: (value: string) => void;
232 options: { value: string; label: string; description?: string }[];
233 disabled?: boolean;
234}) {
235 return (
236 <div>
237 {!hideTitle && <h3 className="mb-2.5 text-sm font-medium text-fg-soft">{title}</h3>}
238 <RadioGroup name={name} value={value} onValueChange={onChange} aria-label={title} disabled={disabled}>
239 {options.map((option) => (
240 <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled={disabled} />
241 ))}
242 </RadioGroup>
243 </div>
244 );
245}