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