Skip to content
101 linesCodeBlameRaw
1import { Hash, Search } from "lucide-react";
2import { useMemo, useState } from "react";
3import { Link, useNavigate } from "react-router";
4
5import type { Channel } from "@g1t/contracts";
6
7import type { Route } from "./+types/browse";
8import { ChatUnavailable } from "../../../components/chat/empty";
9import { CreateChannelButton, useChatSend } from "../../../components/chat/actions";
10import { channelPath } from "../../../lib/chat";
11import { sidebarOrNull } from "../../../lib/chat.server";
12import { page } from "../../../lib/meta";
13import { chat } from "../../../lib/services.server";
14import { requireUser } from "../../../lib/session.server";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `Browse channels · ${params.owner} · g1t` });
18}
19
20/** Every public channel in the workspace, joined or not. */
21export async function loader({ params, context, request }: Route.LoaderArgs) {
22 const viewer = requireUser(context, request);
23 const slug = params.owner.toLowerCase();
24 const [open, sidebar] = await Promise.all([chat.browse(slug, viewer).catch(() => null), sidebarOrNull(slug, viewer)]);
25 if (!open?.ok) return { slug, unavailable: true as const };
26 const mine = new Set((sidebar?.entries ?? []).map((entry) => entry.channel.id));
27 const listed = new Map<string, Channel>();
28 for (const channel of open.value) listed.set(channel.id, channel);
29 for (const entry of sidebar?.entries ?? []) if (entry.channel.kind === "channel") listed.set(entry.channel.id, entry.channel);
30 const channels = [...listed.values()].filter((channel) => !channel.archived_at).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
31 return { slug, unavailable: false as const, channels, joined: [...mine] };
32}
33
34export default function Browse({ loaderData }: Route.ComponentProps) {
35 const [query, setQuery] = useState("");
36 const navigate = useNavigate();
37 const send = useChatSend(loaderData.slug);
38 const [joining, setJoining] = useState<string | null>(null);
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">Browse channels</h1>
51 <p className="mt-1 text-sm text-muted">Every public channel in {loaderData.slug}. Anyone in the workspace can read and join them.</p>
52 </div>
53 <CreateChannelButton slug={loaderData.slug} variant="button" />
54 </div>
55 <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">
56 <Search size={15} className="text-faint" />
57 <input
58 value={query}
59 onChange={(event) => setQuery(event.target.value)}
60 placeholder="Search channels"
61 aria-label="Search channels"
62 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
63 />
64 </label>
65 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
66 {shown.length === 0 && <li className="px-4 py-10 text-center text-sm text-muted">No channel matches.</li>}
67 {shown.map((channel) => {
68 const isIn = joined.has(channel.id);
69 return (
70 <li key={channel.id} className="flex items-center gap-3 px-4 py-3">
71 <Hash size={16} className="shrink-0 text-faint" />
72 <div className="min-w-0 grow">
73 <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent">
74 {channel.name}
75 </Link>
76 {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
77 </div>
78 {isIn ? (
79 <span className="text-xs text-faint">Joined</span>
80 ) : (
81 <button
82 type="button"
83 disabled={joining === channel.id}
84 onClick={async () => {
85 setJoining(channel.id);
86 const done = await send({ intent: "join", channel_id: channel.id });
87 setJoining(null);
88 if (done.ok) navigate(channelPath(loaderData.slug, channel));
89 }}
90 className="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"
91 >
92 {joining === channel.id ? "Joining…" : "Join"}
93 </button>
94 )}
95 </li>
96 );
97 })}
98 </ul>
99 </div>
100 );
101}