| 1 | import { Archive, Hash, Lock, Search } from "lucide-react"; |
| 2 | import { useMemo, useState } from "react"; |
| 3 | import { Link, useNavigate } from "react-router"; |
| 4 | |
| 5 | import type { Route } from "./+types/browse"; |
| 6 | import { ChatUnavailable } from "../../../components/chat/empty"; |
| 7 | import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions"; |
| 8 | import { channelPath } from "../../../lib/chat"; |
| 9 | import { sidebarOrNull } from "../../../lib/chat.server"; |
| 10 | import { page } from "../../../lib/meta"; |
| 11 | import { chat } from "../../../lib/services.server"; |
| 12 | import { requireUser } from "../../../lib/session.server"; |
| 13 | |
| 14 | export 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 | */ |
| 23 | export 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 | |
| 33 | export 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 | } |