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.

157 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 {
35 slug,
36 unavailable: false as const,
37 archived,
38 channels: listed.value,
39 joined,
40 guest: sidebar?.guest === true,
41 name: sidebar?.workspace?.name ?? slug,
42 };
43}
44
45export default function Browse({ loaderData }: Route.ComponentProps) {
46 const [query, setQuery] = useState("");
47 const navigate = useNavigate();
48 const send = useChatSend(loaderData.slug);
49 const [joining, setJoining] = useState<string | null>(null);
50 const may = useChannelCreation();
51 const shown = useMemo(() => {
52 if (loaderData.unavailable) return [];
53 const q = query.trim().toLowerCase().replace(/^#/, "");
54 return loaderData.channels.filter((c) => !q || (c.name ?? "").includes(q) || (c.topic ?? "").toLowerCase().includes(q));
55 }, [loaderData, query]);
56 if (loaderData.unavailable) return <ChatUnavailable title="Browse channels" />;
57 const joined = new Set(loaderData.joined);
58 return (
59 <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12">
60 <div className="flex flex-wrap items-center justify-between gap-3">
61 <div>
62 <h1 className="text-2xl font-semibold tracking-tight">
63 {loaderData.archived ? "Archived channels" : loaderData.guest ? `Public channels in ${loaderData.name}` : "Browse channels"}
64 </h1>
65 <p className="mt-1 text-sm text-muted">
66 {loaderData.archived
67 ? "Channels no one posts in any more. Their history stays readable; whoever may archive a channel may bring it back."
68 : loaderData.guest
69 ? `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.`
70 : `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.`}
71 </p>
72 {!loaderData.archived && !loaderData.guest && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>}
73 </div>
74 <div className="flex items-center gap-2">
75 <Link
76 to={loaderData.archived ? `/${loaderData.slug}/-/chat/browse` : `/${loaderData.slug}/-/chat/browse?archived=1`}
77 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"
78 >
79 {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />}
80 {loaderData.archived ? "Current channels" : "Archived"}
81 </Link>
82 {!loaderData.archived && !loaderData.guest && <CreateChannelButton slug={loaderData.slug} variant="button" />}
83 </div>
84 </div>
85 <Card asChild radius="lg" className="mt-6 flex h-10 items-center gap-2 px-3 text-sm focus-within:border-accent-dim">
86 <label>
87 <Search size={15} className="text-faint" />
88 <input
89 value={query}
90 onChange={(event) => setQuery(event.target.value)}
91 placeholder="Search channels"
92 aria-label="Search channels"
93 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
94 />
95 </label>
96 </Card>
97 <Card asChild divided className="mt-4 overflow-hidden">
98 <ul>
99 {shown.length === 0 && (
100 <li className="px-4 py-10 text-center text-sm text-muted">
101 {query ? "No channel matches." : loaderData.archived ? "No archived channels." : "No channels yet."}
102 </li>
103 )}
104 {shown.map((channel) => {
105 const isIn = joined.has(channel.id);
106 return (
107 // The whole row opens the channel: its name's link reaches over
108 // it (`after:inset-0`), and Join sits above that, so nothing
109 // interactive nests inside anything else.
110 <li
111 key={channel.id}
112 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"
113 >
114 <span className="flex w-4 shrink-0 justify-center text-faint">
115 <ChannelMark channel={channel} size={16} />
116 </span>
117 <div className="min-w-0 grow">
118 <Link
119 to={channelPath(loaderData.slug, channel)}
120 className="font-medium outline-none group-hover/card:text-accent after:absolute after:inset-0 after:content-['']"
121 >
122 {channel.name}
123 </Link>
124 {channel.visibility !== "workspace" && (
125 <span className="ml-2 text-xs text-faint">{channel.visibility === "public" ? "Public · anyone on g1t" : "Private"}</span>
126 )}
127 {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
128 </div>
129 {loaderData.archived ? (
130 <span className="text-xs text-faint">Archived</span>
131 ) : isIn ? (
132 <span className="text-xs text-faint">Joined</span>
133 ) : (
134 <Button
135 type="button"
136 disabled={joining === channel.id}
137 onClick={async () => {
138 setJoining(channel.id);
139 const done = await send({ intent: "join", channel_id: channel.id });
140 setJoining(null);
141 if (done.ok) navigate(channelPath(loaderData.slug, channel));
142 }}
143 variant="outline"
144 size="sm"
145 className="relative z-10 px-3 text-fg/90 hover:bg-raised"
146 >
147 {joining === channel.id ? "Joining…" : "Join"}
148 </Button>
149 )}
150 </li>
151 );
152 })}
153 </ul>
154 </Card>
155 </div>
156 );
157}