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