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.

149 linesCodeBlameRaw
1import { Archive, Hash, Search } from "lucide-react";
2import { useMemo, useState } from "react";
3import { Link, useNavigate } from "react-router";
4
5import type { Route } from "./+types/browse";
6import { ChatUnavailable } from "../../../components/chat/empty";
7import { ChannelMark } from "../../../components/chat/sidebar";
8import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions";
9import { channelPath } from "../../../lib/chat";
10import { sidebarOrNull } from "../../../lib/chat.server";
11import { page } from "../../../lib/meta";
12import { chat } from "../../../lib/services.server";
13import { requireUser } from "../../../lib/session.server";
14import { Button } from "../../../components/ui/button";
15import { Card } from "../../../components/ui/card";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Browse channels · ${params.owner} · g1t` });
19}
20
21/**
22 * Every channel the viewer may join, joined or not: for a member, those
23 * kept to the workspace and the public ones; for a guest, the public ones
24 * only; and the private ones the viewer is in (the chat service leaves
25 * out the rest). With `?archived=1`, the archived ones instead.
26 */
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const viewer = requireUser(context, request);
29 const slug = params.owner.toLowerCase();
30 const archived = new URL(request.url).searchParams.get("archived") === "1";
31 const [listed, sidebar] = await Promise.all([chat.browse(slug, viewer, { archived }).catch(() => null), sidebarOrNull(slug, viewer)]);
32 if (!listed?.ok) return { slug, unavailable: true as const };
33 const joined = (sidebar?.entries ?? []).map((entry) => entry.channel.id);
34 return { slug, unavailable: false as const, archived, channels: listed.value, joined, guest: sidebar?.guest === true, name: sidebar?.workspace?.name ?? slug };
35}
36
37export default function Browse({ loaderData }: Route.ComponentProps) {
38 const [query, setQuery] = useState("");
39 const navigate = useNavigate();
40 const send = useChatSend(loaderData.slug);
41 const [joining, setJoining] = useState<string | null>(null);
42 const may = useChannelCreation();
43 const shown = useMemo(() => {
44 if (loaderData.unavailable) return [];
45 const q = query.trim().toLowerCase().replace(/^#/, "");
46 return loaderData.channels.filter((c) => !q || (c.name ?? "").includes(q) || (c.topic ?? "").toLowerCase().includes(q));
47 }, [loaderData, query]);
48 if (loaderData.unavailable) return <ChatUnavailable title="Browse channels" />;
49 const joined = new Set(loaderData.joined);
50 return (
51 <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12">
52 <div className="flex flex-wrap items-center justify-between gap-3">
53 <div>
54 <h1 className="text-2xl font-semibold tracking-tight">
55 {loaderData.archived ? "Archived channels" : loaderData.guest ? `Public channels in ${loaderData.name}` : "Browse channels"}
56 </h1>
57 <p className="mt-1 text-sm text-muted">
58 {loaderData.archived
59 ? "Channels no one posts in any more. Their history stays readable; whoever may archive a channel may bring it back."
60 : loaderData.guest
61 ? `You're not a member of ${loaderData.name}. These channels are open to anyone on g1t: read them, join them and talk in them as a guest.`
62 : `Every channel in ${loaderData.name} anyone in the workspace can read and join, its public ones open to anyone on g1t, and the private ones you're in.`}
63 </p>
64 {!loaderData.archived && !loaderData.guest && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>}
65 </div>
66 <div className="flex items-center gap-2">
67 <Link
68 to={loaderData.archived ? `/${loaderData.slug}/-/chat/browse` : `/${loaderData.slug}/-/chat/browse?archived=1`}
69 className="inline-flex h-9 items-center gap-2 rounded-md px-3 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
70 >
71 {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />}
72 {loaderData.archived ? "Current channels" : "Archived"}
73 </Link>
74 {!loaderData.archived && !loaderData.guest && <CreateChannelButton slug={loaderData.slug} variant="button" />}
75 </div>
76 </div>
77 <Card asChild radius="lg" className="mt-6 flex h-10 items-center gap-2 px-3 text-sm focus-within:border-accent-dim">
78 <label>
79 <Search size={15} className="text-faint" />
80 <input
81 value={query}
82 onChange={(event) => setQuery(event.target.value)}
83 placeholder="Search channels"
84 aria-label="Search channels"
85 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
86 />
87 </label>
88 </Card>
89 <Card asChild divided className="mt-4 overflow-hidden">
90 <ul>
91 {shown.length === 0 && (
92 <li className="px-4 py-10 text-center text-sm text-muted">
93 {query ? "No channel matches." : loaderData.archived ? "No archived channels." : "No channels yet."}
94 </li>
95 )}
96 {shown.map((channel) => {
97 const isIn = joined.has(channel.id);
98 return (
99 // The whole row opens the channel: its name's link reaches over
100 // it (`after:inset-0`), and Join sits above that, so nothing
101 // interactive nests inside anything else.
102 <li
103 key={channel.id}
104 className="group/card relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/50 has-[a:focus-visible]:bg-raised/50 has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-accent has-[a:focus-visible]:ring-inset"
105 >
106 <span className="flex w-4 shrink-0 justify-center text-faint">
107 <ChannelMark channel={channel} size={16} />
108 </span>
109 <div className="min-w-0 grow">
110 <Link
111 to={channelPath(loaderData.slug, channel)}
112 className="font-medium outline-none group-hover/card:text-accent after:absolute after:inset-0 after:content-['']"
113 >
114 {channel.name}
115 </Link>
116 {channel.visibility !== "workspace" && (
117 <span className="ml-2 text-xs text-faint">{channel.visibility === "public" ? "Public · anyone on g1t" : "Private"}</span>
118 )}
119 {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
120 </div>
121 {loaderData.archived ? (
122 <span className="text-xs text-faint">Archived</span>
123 ) : isIn ? (
124 <span className="text-xs text-faint">Joined</span>
125 ) : (
126 <Button
127 type="button"
128 disabled={joining === channel.id}
129 onClick={async () => {
130 setJoining(channel.id);
131 const done = await send({ intent: "join", channel_id: channel.id });
132 setJoining(null);
133 if (done.ok) navigate(channelPath(loaderData.slug, channel));
134 }}
135 variant="outline"
136 size="sm"
137 className="relative z-10 px-3 text-fg/90 hover:bg-raised"
138 >
139 {joining === channel.id ? "Joining…" : "Join"}
140 </Button>
141 )}
142 </li>
143 );
144 })}
145 </ul>
146 </Card>
147 </div>
148 );
149}