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.

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 workspace 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 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
160 moved.
161 </p>
162 {view.channels.length === 0 ? (
163 <p className="mt-5 text-sm text-muted">No workspace or 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}