| 1 | import { Check, Hash, Lock, MessagesSquare, PlugZap } from "lucide-react"; |
| 2 | import { type ReactNode, useState } from "react"; |
| 3 | import { useParams } from "react-router"; |
| 4 | |
| 5 | import { CreateChannelButton, NewMessageButton } from "./actions"; |
| 6 | import { Button } from "../ui/button"; |
| 7 | import { Card } from "../ui/card"; |
| 8 | import { Textarea } from "../ui/textarea"; |
| 9 | |
| 10 | /** A calm full-height panel for a conversation that cannot be shown. */ |
| 11 | function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { |
| 12 | return ( |
| 13 | <div className="flex h-(--page-h) items-center justify-center px-6"> |
| 14 | <div className="max-w-sm text-center"> |
| 15 | <Card className="mx-auto flex size-12 items-center justify-center text-muted">{icon}</Card> |
| 16 | <h1 className="mt-4 text-base font-semibold">{title}</h1> |
| 17 | <div className="mt-1.5 text-sm text-muted">{children}</div> |
| 18 | </div> |
| 19 | </div> |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | /** Chat did not answer: it may be deploying a moment after the site. */ |
| 24 | export function ChatUnavailable({ title = "Chat" }: { title?: string }) { |
| 25 | return ( |
| 26 | <Panel icon={<PlugZap size={20} />} title={`${title} isn't reachable right now`}> |
| 27 | Chat didn't answer. It's usually back within a minute; reload to try again. Nothing you sent is lost. |
| 28 | </Panel> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | /** A workspace with no conversations yet. */ |
| 33 | export function NoChannels() { |
| 34 | const { owner = "" } = useParams(); |
| 35 | return ( |
| 36 | <Panel icon={<MessagesSquare size={20} />} title="Start the first conversation"> |
| 37 | <p>Channels keep a topic in one place. Direct messages are for one person or a few.</p> |
| 38 | <div className="mt-5 flex justify-center gap-2"> |
| 39 | <CreateChannelButton slug={owner.toLowerCase()} variant="button" /> |
| 40 | <NewMessageButton slug={owner.toLowerCase()} variant="button" /> |
| 41 | </div> |
| 42 | </Panel> |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | /** A channel's name with its mark, as the header and the sidebar show it. */ |
| 47 | export function ChannelGlyph({ className }: { className?: string }) { |
| 48 | return <Hash size={16} className={className} />; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * A channel's link that does not open for the viewer: private, kept to a |
| 53 | * workspace they are not in, or not there at all, which reads the same so |
| 54 | * nothing about a channel they may not see is told. They may ask whoever |
| 55 | * could add them to let them in, with a note. |
| 56 | */ |
| 57 | export function RequestAccess({ name, workspace, member }: { name: string; workspace: { slug: string; name: string }; member: boolean }) { |
| 58 | const [note, setNote] = useState(""); |
| 59 | const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle"); |
| 60 | const ask = async () => { |
| 61 | setState("sending"); |
| 62 | try { |
| 63 | const response = await fetch(`/${workspace.slug}/-/chat/api`, { |
| 64 | method: "POST", |
| 65 | headers: { "content-type": "application/json" }, |
| 66 | body: JSON.stringify({ intent: "request_access", name, note }), |
| 67 | }); |
| 68 | const result = (await response.json()) as { ok: boolean }; |
| 69 | setState(result.ok ? "sent" : "failed"); |
| 70 | } catch { |
| 71 | setState("failed"); |
| 72 | } |
| 73 | }; |
| 74 | return ( |
| 75 | <Panel icon={<Lock size={20} />} title={`You can't open #${name}`}> |
| 76 | <p> |
| 77 | {member |
| 78 | ? `If #${name} is a private channel in ${workspace.name}, only the people in it can read it.` |
| 79 | : `You're not a member of ${workspace.name}. If #${name} is one of its channels, it's for its members or the people asked into it.`}{" "} |
| 80 | You can ask to be let in: whoever could add you will see your request. |
| 81 | </p> |
| 82 | {state === "sent" ? ( |
| 83 | <p className="mt-5 inline-flex items-center gap-1.5 text-fg"> |
| 84 | <Check size={15} className="text-accent" /> |
| 85 | Asked. You'll find it in your sidebar once someone lets you in. |
| 86 | </p> |
| 87 | ) : ( |
| 88 | <div className="mt-5 space-y-2 text-left"> |
| 89 | <Textarea |
| 90 | value={note} |
| 91 | onChange={(event) => setNote(event.target.value)} |
| 92 | maxLength={300} |
| 93 | rows={3} |
| 94 | placeholder="Say who you are and why you'd like in (optional)" |
| 95 | aria-label="A note for whoever lets you in" |
| 96 | /> |
| 97 | <div className="flex items-center justify-between gap-2"> |
| 98 | <span className="text-xs text-danger">{state === "failed" ? "That didn't go through. Try again." : ""}</span> |
| 99 | <Button type="button" onClick={() => void ask()} disabled={state === "sending"}> |
| 100 | {state === "sending" ? "Asking…" : "Ask to join"} |
| 101 | </Button> |
| 102 | </div> |
| 103 | </div> |
| 104 | )} |
| 105 | </Panel> |
| 106 | ); |
| 107 | } |