Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

249 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>Only workspace owners can change these. They are shown so you know what members can do here.</p>
85 </Card>
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">
92 Today {settings.public_channels === "owners" ? "only owners" : "any member"} can create a workspace channel and{" "}
93 {settings.private_channels === "owners" ? "only owners" : "any member"} a private one.{" "}
94 {owner ? (
95 <>
96 Change it under{" "}
97 <Link to={`/${slug}/-/permissions`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
98 Permissions
99 </Link>
100 , with everything else about who may do what.
101 </>
102 ) : (
103 "Owners change it under Permissions."
104 )}
105 </p>
106 </section>
107
108 <section className={SECTION}>
109 <h2 className="font-medium">Renaming and archiving channels</h2>
110 <p className="mt-1.5 text-xs text-faint">
111 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 archived.
112 </p>
113 <div className="mt-5">
114 <Choice
115 name="manage_channels"
116 title="Who can rename and archive"
117 hideTitle
118 value={settings.manage_channels}
119 onChange={(value) => set("manage_channels", value as ChannelManagers)}
120 options={[
121 {
122 value: "channel_owners",
123 label: "Channel owners and workspace owners",
124 description: "Whoever created a channel looks after it. The default.",
125 },
126 { value: "owners", label: "Workspace owners only" },
127 ]}
128 disabled={!owner}
129 />
130 </div>
131 </section>
132
133 <section className={SECTION}>
134 <h2 className="font-medium">Custom emoji</h2>
135 <p className="mt-1.5 text-xs text-faint">
136 Who can add the workspace&apos;s own emoji and aliases. Whoever added one, and owners, can remove it.{" "}
137 <Link to={`/${slug}/-/emoji`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
138 Manage emoji
139 </Link>
140 </p>
141 <div className="mt-5">
142 <Choice
143 name="emoji_upload"
144 title="Who can add emoji"
145 hideTitle
146 value={settings.emoji_upload}
147 onChange={(value) => set("emoji_upload", value as EmojiUpload)}
148 options={[
149 { value: "members", label: "Any member", description: "The default." },
150 { value: "admins", label: "Owners only", description: "Members use the workspace's emoji; only owners add them." },
151 ]}
152 disabled={!owner}
153 />
154 </div>
155 </section>
156
157 <section className={SECTION}>
158 <h2 className="font-medium">Default channels</h2>
159 <p className="mt-1.5 text-xs text-faint">
160 The workspace and public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not
161 moved.
162 </p>
163 {view.channels.length === 0 ? (
164 <p className="mt-5 text-sm text-muted">No workspace or public channels yet.</p>
165 ) : (
166 <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
167 {view.channels.map((channel) => (
168 <li key={channel.id}>
169 {defaults.has(channel.id) && <input type="hidden" name="default_channels" value={channel.id} />}
170 <CheckboxOption
171 checked={defaults.has(channel.id)}
172 disabled={!owner}
173 onCheckedChange={(on) =>
174 set(
175 "default_channels",
176 on === true ? [...settings.default_channels, channel.id] : settings.default_channels.filter((id) => id !== channel.id),
177 )
178 }
179 label={
180 <span className="inline-flex items-center gap-1">
181 <Hash size={13} className="text-faint" />
182 {channel.name}
183 </span>
184 }
185 description={channel.topic ?? undefined}
186 />
187 </li>
188 ))}
189 </ul>
190 )}
191 </section>
192 </fieldset>
193
194 {owner && (
195 <div className="flex items-center gap-3 border-t border-line pt-6">
196 <Button type="submit" variant="accent" disabled={saving || !changed}>
197 {saving ? "Saving…" : "Save"}
198 </Button>
199 {actionData && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>}
200 {actionData?.ok && !changed && (
201 <p role="status" className="text-xs text-muted">
202 Saved.
203 </p>
204 )}
205 </div>
206 )}
207 </Form>
208 </div>
209 );
210}
211
212function Header() {
213 return (
214 <header className="mb-8 border-b border-line pb-6">
215 <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
216 <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>
217 </header>
218 );
219}
220
221/** One setting's choices, posted as `name`. */
222function Choice({
223 name,
224 title,
225 hideTitle,
226 value,
227 onChange,
228 options,
229 disabled,
230}: {
231 name: string;
232 title: string;
233 hideTitle?: boolean;
234 value: string;
235 onChange: (value: string) => void;
236 options: { value: string; label: string; description?: string }[];
237 disabled?: boolean;
238}) {
239 return (
240 <div>
241 {!hideTitle && <h3 className="mb-2.5 text-sm font-medium text-fg-soft">{title}</h3>}
242 <RadioGroup name={name} value={value} onValueChange={onChange} aria-label={title} disabled={disabled}>
243 {options.map((option) => (
244 <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled={disabled} />
245 ))}
246 </RadioGroup>
247 </div>
248 );
249}