Skip to content
253 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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