Public channels, guests and requests to join in Chat (#44) #46
Closeds
syntaqx wants to merge public-channels into main
27 files+1666โ2090/27 viewed
| 17 | 17 | answer arrives in the place you asked. | |
| 18 | 18 | ||
| 19 | 19 | Chat is open to every member of a workspace, on every plan, the free one | |
| 20 | โ | too. Chat is never charged: no seats, no per-message price and no history | |
| 21 | โ | cutoff. | |
| 20 | + | too. People outside it can talk in its [public channels](#public-channels) | |
| 21 | + | and in the channels they are [asked into as guests](#guests). Chat is never | |
| 22 | + | charged: no seats, no per-message price and no history cutoff. | |
| 22 | 23 | ||
| 23 | 24 | Open **Chat** in the rail on the left, or go to | |
| 24 | 25 | `g1t.sh/<workspace>/-/chat`. | |
| โฏ | |||
| 65 | 66 | A channel is a named conversation, such as `#web`, `#releases` or | |
| 66 | 67 | `#support`. Every workspace starts with `#general`, and everyone new is put | |
| 67 | 68 | in it the first time they open Chat (owners can choose other | |
| 68 | โ | [default channels](#workspace-chat-settings)). A private channel shows a | |
| 69 | โ | lock in place of `#`, in the sidebar, in Browse channels and at the top of | |
| 70 | โ | the channel. | |
| 69 | + | [default channels](#workspace-chat-settings)). | |
| 71 | 70 | ||
| 72 | โ | | | Public channel | Private channel | | |
| 73 | โ | | --- | --- | --- | | |
| 74 | โ | | Who can find it | Every member, under **Browse channels** | Only its members | | |
| 75 | โ | | Who can join | Any member, by opening it and choosing **Join** | Only people a member adds | | |
| 76 | โ | | Who can read it | Every member | Only its members | | |
| 71 | + | Each channel is one of three kinds, and its mark says which, in the | |
| 72 | + | sidebar, in Browse channels and at the top of the channel: | |
| 73 | + | ||
| 74 | + | | | Workspace `#` | Private ๐ | Public ๐ | | |
| 75 | + | | --- | --- | --- | --- | | |
| 76 | + | | Who can find it | Every member, under **Browse channels** | Only the people in it | Every member, and anyone on g1t with its link | | |
| 77 | + | | Who can join | Any member | Only people someone in it adds, or lets in on a [request](#ask-to-join) | Anyone signed in to g1t | | |
| 78 | + | | Who can read it | Every member | Only the people in it | Anyone signed in to g1t | | |
| 79 | + | | Who can make one | Members, unless owners keep it to themselves | Members, unless owners keep it to themselves | Workspace owners | | |
| 77 | 80 | ||
| 81 | + | Anyone in a channel who is not a member of the workspace shows as a | |
| 82 | + | **Guest**, beside their name on every message, in the channel's people and | |
| 83 | + | in its details, so who is in the workspace is never in doubt. | |
| 84 | + | ||
| 78 | 85 | ### Create a channel | |
| 79 | 86 | ||
| 80 | 87 | <Steps> | |
| โฏ | |||
| 84 | 91 | lowercase letters, digits, dashes and underscores. `Web Team` becomes | |
| 85 | 92 | `web-team`. | |
| 86 | 93 | 3. Optionally, say what the channel is for. That line shows under its name. | |
| 87 | โ | 4. Turn on **Private** if only the people you invite should find and read | |
| 88 | โ | it. | |
| 94 | + | 4. Under **Who can open it**, choose **Workspace**, **Private**, or (for | |
| 95 | + | workspace owners) **Public**. | |
| 89 | 96 | 5. Choose **Create channel**. You are its owner and first member. Invite | |
| 90 | 97 | people once it is made. | |
| 91 | 98 | ||
| 92 | 99 | </Steps> | |
| 93 | 100 | ||
| 94 | โ | A workspace's owners can keep creating public channels, private channels or | |
| 95 | โ | both to themselves ([chat settings](#workspace-chat-settings)). When they | |
| 101 | + | A workspace's owners can keep creating workspace channels, private channels | |
| 102 | + | or both to themselves ([chat settings](#workspace-chat-settings)). When they | |
| 96 | 103 | have, **Create a channel** is turned off for everyone else and says why, and | |
| 97 | โ | someone who may create only one kind gets that kind. | |
| 104 | + | a kind someone may not create is shown turned off, with why. Only owners | |
| 105 | + | make a channel public. | |
| 106 | + | ||
| 107 | + | ### Public channels | |
| 108 | + | ||
| 109 | + | A public channel is open to anyone signed in to g1t with a confirmed email | |
| 110 | + | address: a community, a support desk, an open project's chat. Share its | |
| 111 | + | link, `g1t.sh/<workspace>/-/chat/<channel>`; anyone who opens it reads it, | |
| 112 | + | and **Join** (or simply writing in it) puts it in their sidebar. | |
| 98 | 113 | ||
| 114 | + | - People outside the workspace talk there as **guests**. They see that | |
| 115 | + | channel and the workspace's other public ones, and nothing else of the | |
| 116 | + | workspace: no other channels, no direct messages, no people directory. | |
| 117 | + | - **@g1t works for the workspace's members only.** Guests never start a | |
| 118 | + | conversation with g1t, in a public channel or anywhere else, so nobody | |
| 119 | + | outside the workspace spends its AI. | |
| 120 | + | - Only a workspace owner makes a channel public, when creating it or later | |
| 121 | + | from its details (**Open to anyone on g1t**). A private channel stays | |
| 122 | + | private. Turning it off keeps the channel to the workspace again: guests | |
| 123 | + | who joined on their own leave it, and guests someone asked in stay. | |
| 124 | + | - A channel's owners and the workspace's owners can take anyone out of it | |
| 125 | + | from its details. Someone outside the workspace taken out of a public | |
| 126 | + | channel cannot join it again on their own; asking them in again lifts | |
| 127 | + | that. | |
| 128 | + | ||
| 129 | + | ### Guests | |
| 130 | + | ||
| 131 | + | A guest is someone outside the workspace in one of its channels. Besides | |
| 132 | + | joining a public channel, someone can be asked into any channel, private | |
| 133 | + | ones too, without joining the workspace: a contractor, a customer, a | |
| 134 | + | friend from another team. | |
| 135 | + | ||
| 136 | + | <Steps> | |
| 137 | + | ||
| 138 | + | 1. Open the channel's details and choose **Add**. | |
| 139 | + | 2. Type their g1t username and choose **Invite @username as a guest**. | |
| 140 | + | 3. They see the invitation in their own Chat sidebar, under | |
| 141 | + | **Invitations**, and choose **Join** or **Decline**. | |
| 142 | + | ||
| 143 | + | </Steps> | |
| 144 | + | ||
| 145 | + | A guest's channels show in their own sidebar under **Other workspaces**, | |
| 146 | + | with their unread counts. Opening one shows that workspace's chat as a | |
| 147 | + | guest sees it, with a **Guest** mark at the top and a way back to their | |
| 148 | + | own. | |
| 149 | + | ||
| 150 | + | Owners can always ask guests in. Members can too, unless owners turned off | |
| 151 | + | **Members can invite outside collaborators** in the workspace's | |
| 152 | + | [permissions](/guides/workspaces/#member-privileges), the same setting as | |
| 153 | + | for repositories. Guests themselves add no one. Pending invitations show in | |
| 154 | + | the channel's details, where whoever may take people out can take one | |
| 155 | + | back. | |
| 156 | + | ||
| 157 | + | ### Ask to join | |
| 158 | + | ||
| 159 | + | Opening the link of a channel you cannot open (a private one, or a | |
| 160 | + | workspace one when you are not in the workspace) shows **You can't open | |
| 161 | + | #channel** and a way to **Ask to join**, with an optional note. The page is | |
| 162 | + | the same whether or not the channel exists, so asking never tells anyone | |
| 163 | + | about a channel they may not see. | |
| 164 | + | ||
| 165 | + | The people who could add you see your request in the channel's details, | |
| 166 | + | with a count on its members button, and choose **Let in** or **Decline**. | |
| 167 | + | Anyone in the channel can let in a member of the workspace; someone outside | |
| 168 | + | it is let in, as a guest, by those who may invite guests. | |
| 169 | + | ||
| 99 | 170 | ### Join, leave and browse | |
| 100 | 171 | ||
| 101 | โ | - **Browse channels** lists every public channel, and the private ones | |
| 102 | โ | you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the end of the | |
| 103 | โ | sidebar's **Channels**. Choose a channel anywhere on its row to read it, | |
| 172 | + | - **Browse channels** lists every workspace and public channel, and the | |
| 173 | + | private ones you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the | |
| 174 | + | end of the sidebar's **Channels**. A guest sees the public ones there. Choose a channel anywhere on its row to read it, | |
| 104 | 175 | and choose **Join** to post in it and see it in your sidebar. A private channel | |
| 105 | 176 | you're not in never appears. | |
| 106 | 177 | - **Archived**, on the same page, lists archived channels. | |
| 107 | โ | - You can leave a channel, and join a public one again at any time. | |
| 178 | + | - You can leave a channel, and join a workspace or public one again at any time. | |
| 108 | 179 | - When you join, everything already said counts as read, so a busy channel | |
| 109 | 180 | doesn't greet you with its whole history as unread. | |
| 110 | 181 | ||
| โฏ | |||
| 394 | 465 | ||
| 395 | 466 | | Setting | Choices | | |
| 396 | 467 | | --- | --- | | |
| 397 | โ | | **Creating channels** | Who can create public and private channels (**Any member**, the default, or **Owners only**) is set with the workspace's other permissions, under [Permissions โ Chat](/guides/permissions/#chat); this page says what is allowed today and leads there. | | |
| 468 | + | | **Creating channels** | Who can create workspace and private channels (**Any member**, the default, or **Owners only**) is set with the workspace's other permissions, under [Permissions โ Chat](/guides/permissions/#chat); this page says what is allowed today and leads there. | | |
| 398 | 469 | | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. | | |
| 399 | 470 | | **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. | | |
| 400 | โ | | **Default channels** | The public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. | | |
| 471 | + | | **Default channels** | The workspace and public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. | | |
| 401 | 472 | ||
| 402 | 473 | Changing a setting doesn't change what already exists: channels already | |
| 403 | 474 | made stay, whoever made them. | |
| 36 | 36 | ||
| 37 | 37 | | Setting | What it decides | Today | | |
| 38 | 38 | | --- | --- | --- | | |
| 39 | โ | | **Public channels** | Who can create them: **Any member** or **Owners only**. | Any member | | |
| 39 | + | | **Workspace channels** | Who can create channels anyone in the workspace can join: **Any member** or **Owners only**. Public channels, open to anyone on g1t, are always made by owners. | Any member | | |
| 40 | 40 | | **Private channels** | Who can create them: **Any member** or **Owners only**. | Any member | | |
| 41 | 41 | ||
| 42 | 42 | Renaming and archiving channels, custom emoji and default channels are on |
| 1 | โ | import { Check, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react"; | |
| 1 | + | import { Check, Globe, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { useFetcher, useNavigate, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | โ | import type { ChatSidebar, Result } from "@g1t/contracts"; | |
| 5 | + | import type { ChannelVisibility, ChatSidebar, Result } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { Avatar } from "../ui/avatar"; | |
| 8 | 8 | import { Button } from "../ui/button"; | |
| 9 | โ | import { Card } from "../ui/card"; | |
| 10 | 9 | import { Hint } from "../ui/hint"; | |
| 11 | 10 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 12 | โ | import { Switch } from "../ui/switch"; | |
| 11 | + | import { RadioGroup, RadioOption } from "../ui/radio-group"; | |
| 13 | 12 | import { channelName, shownHandle, shownName } from "../../lib/chat"; | |
| 14 | 13 | import type { ChatLayoutData } from "../../routes/workspace/chat/layout"; | |
| 15 | 14 | import { useLiveCounts } from "../../lib/notify-client"; | |
| โฏ | |||
| 93 | 92 | * What the viewer may make, from the workspace's chat settings (Settings, | |
| 94 | 93 | * Chat). Until the sidebar arrives, everything: the chat service checks. | |
| 95 | 94 | */ | |
| 96 | โ | export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } { | |
| 95 | + | export function useChannelCreation(): { public: boolean; private: boolean; any: boolean; open: boolean } { | |
| 97 | 96 | const { sidebar } = useChatSidebar(); | |
| 98 | โ | const open = sidebar?.can?.create_public_channels ?? true; | |
| 97 | + | // `create_public_channels` is the setting's old name for channels anyone in the workspace can join. | |
| 98 | + | const shared = sidebar?.can?.create_public_channels ?? true; | |
| 99 | 99 | const closed = sidebar?.can?.create_private_channels ?? true; | |
| 100 | โ | return { public: open, private: closed, any: open || closed }; | |
| 100 | + | // Open to anyone on g1t: the workspace's owners only. | |
| 101 | + | const open = shared && sidebar?.can?.make_public === true; | |
| 102 | + | return { public: shared, private: closed, any: shared || closed, open }; | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | 105 | /** | |
| 104 | โ | * Making a channel: its name, what it is for, and whether it is private. | |
| 105 | โ | * Someone the workspace's settings do not let make one sees the button | |
| 106 | โ | * turned off, with why; someone who may make only one kind gets that kind. | |
| 106 | + | * Making a channel: its name, what it is for, and who can open it: | |
| 107 | + | * private, the workspace, or anyone on g1t (owners only). Someone the | |
| 108 | + | * workspace's settings do not let make one sees the button turned off, | |
| 109 | + | * with why; a kind they may not make is shown off, with why. | |
| 107 | 110 | */ | |
| 108 | 111 | export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) { | |
| 109 | 112 | const [open, setOpen] = useState(false); | |
| 110 | 113 | const [name, setName] = useState(""); | |
| 111 | 114 | const [topic, setTopic] = useState(""); | |
| 112 | โ | const [secret, setSecret] = useState(false); | |
| 115 | + | const [chosen, setChosen] = useState<ChannelVisibility>("workspace"); | |
| 113 | 116 | const [error, setError] = useState<string | null>(null); | |
| 114 | 117 | const [busy, setBusy] = useState(false); | |
| 115 | 118 | const send = useChatSend(slug); | |
| 116 | 119 | const navigate = useNavigate(); | |
| 117 | 120 | const may = useChannelCreation(); | |
| 118 | โ | // Only one kind allowed: that kind, and the switch says why it is fixed. | |
| 119 | โ | const isPrivate = !may.public ? true : !may.private ? false : secret; | |
| 121 | + | // A kind they may not make falls back to one they may. | |
| 122 | + | const visibility: ChannelVisibility = | |
| 123 | + | chosen === "private" && !may.private ? "workspace" : chosen !== "private" && !may.public ? "private" : chosen === "public" && !may.open ? "workspace" : chosen; | |
| 124 | + | const isPrivate = visibility === "private"; | |
| 120 | 125 | const clean = channelName(name); | |
| 121 | 126 | const submit = async () => { | |
| 122 | 127 | if (!clean) return setError("Give the channel a name."); | |
| 123 | 128 | setBusy(true); | |
| 124 | 129 | setError(null); | |
| 125 | โ | const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate }); | |
| 130 | + | const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate, visibility }); | |
| 126 | 131 | setBusy(false); | |
| 127 | 132 | if (!made.ok) return setError(made.error.message); | |
| 128 | 133 | setOpen(false); | |
| โฏ | |||
| 177 | 182 | <label className="grid gap-1.5"> | |
| 178 | 183 | <span className="text-sm font-medium text-muted">Name</span> | |
| 179 | 184 | <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim"> | |
| 180 | โ | {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />} | |
| 185 | + | {isPrivate ? <Lock size={14} className="text-faint" /> : visibility === "public" ? <Globe size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />} | |
| 181 | 186 | <input | |
| 182 | 187 | autoFocus | |
| 183 | 188 | value={name} | |
| โฏ | |||
| 205 | 210 | className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 206 | 211 | /> | |
| 207 | 212 | </label> | |
| 208 | โ | <Card asChild tone="plain" radius="lg" className="flex items-start justify-between gap-4 bg-bg/60 p-3"> | |
| 209 | โ | <label> | |
| 210 | โ | <span> | |
| 211 | โ | <span className="flex items-center gap-1.5 text-sm font-medium"> | |
| 212 | โ | {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />} | |
| 213 | โ | {isPrivate ? "Private" : "Public"} | |
| 214 | โ | </span> | |
| 215 | โ | <span className="mt-0.5 block text-xs text-muted"> | |
| 216 | โ | {!may.public | |
| 217 | โ | ? "Only workspace owners can create public channels here, so this one is private: only people you invite can find and read it." | |
| 218 | โ | : !may.private | |
| 219 | โ | ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it." | |
| 220 | โ | : isPrivate | |
| 221 | โ | ? "Only people you invite can find and read it." | |
| 222 | โ | : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people you invite."} | |
| 223 | โ | </span> | |
| 224 | โ | </span> | |
| 225 | โ | <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" /> | |
| 226 | โ | </label> | |
| 227 | โ | </Card> | |
| 213 | + | <fieldset className="grid gap-2"> | |
| 214 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Who can open it</legend> | |
| 215 | + | <RadioGroup value={visibility} onValueChange={(value) => setChosen(value as ChannelVisibility)} className="grid gap-3"> | |
| 216 | + | <RadioOption | |
| 217 | + | value="workspace" | |
| 218 | + | icon={<Hash />} | |
| 219 | + | label="Workspace" | |
| 220 | + | description={may.public ? "Anyone in the workspace can find, read and join it." : "Only workspace owners can create these here."} | |
| 221 | + | disabled={!may.public} | |
| 222 | + | /> | |
| 223 | + | <RadioOption | |
| 224 | + | value="private" | |
| 225 | + | icon={<Lock />} | |
| 226 | + | label="Private" | |
| 227 | + | description={may.private ? "Only the people you invite can find and read it. Others can ask to join." : "Only workspace owners can create these here."} | |
| 228 | + | disabled={!may.private} | |
| 229 | + | /> | |
| 230 | + | <RadioOption | |
| 231 | + | value="public" | |
| 232 | + | icon={<Globe />} | |
| 233 | + | label="Public" | |
| 234 | + | description={ | |
| 235 | + | may.open | |
| 236 | + | ? "Anyone signed in to g1t can read, join and talk in it, marked Guest. A community or support channel." | |
| 237 | + | : "Open to anyone on g1t. Only workspace owners can make one." | |
| 238 | + | } | |
| 239 | + | disabled={!may.open} | |
| 240 | + | /> | |
| 241 | + | </RadioGroup> | |
| 242 | + | </fieldset> | |
| 228 | 243 | {error && <p className="text-sm text-danger">{error}</p>} | |
| 229 | 244 | <DialogFooter> | |
| 230 | 245 | <Button type="button" onClick={() => setOpen(false)} variant="outline"> | |
| 24 | 24 | ||
| 25 | 25 | import { | |
| 26 | 26 | type Channel, | |
| 27 | + | type ChannelAccessRequest, | |
| 27 | 28 | type ChannelChange, | |
| 29 | + | type ChannelInvitation, | |
| 30 | + | type ChannelVisibility, | |
| 28 | 31 | type ChannelMember, | |
| 29 | 32 | type ChatLiveEvent, | |
| 30 | 33 | type ChatMessage, | |
| โฏ | |||
| 39 | 42 | import { CardBox, CardToasts, showToast } from "./card"; | |
| 40 | 43 | import { Composer } from "./composer"; | |
| 41 | 44 | import { type LiveState, useChatLive } from "./live"; | |
| 42 | โ | import { AgentPill, MemberAvatar } from "./marks"; | |
| 45 | + | import { AgentPill, GuestPill, MemberAvatar } from "./marks"; | |
| 46 | + | import { ChannelMark } from "./sidebar"; | |
| 43 | 47 | import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence"; | |
| 44 | 48 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; | |
| 45 | 49 | ||
| โฏ | |||
| 71 | 75 | import { Button } from "../ui/button"; | |
| 72 | 76 | import { Card } from "../ui/card"; | |
| 73 | 77 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 78 | + | import { Switch } from "../ui/switch"; | |
| 74 | 79 | import { | |
| 75 | 80 | type Mentionable, | |
| 76 | 81 | type ShownMessage, | |
| โฏ | |||
| 783 | 788 | <span>{formerAgentLine(formerAgent)}</span> | |
| 784 | 789 | </Card> | |
| 785 | 790 | ) : joined ? ( | |
| 791 | + | <> | |
| 792 | + | {data.guest && ( | |
| 793 | + | <p className="mb-1.5 px-1 text-xs text-faint"> | |
| 794 | + | You're a guest in {data.workspace?.name ?? "this workspace"}: everyone here sees you marked Guest, and @g1t answers its members only. | |
| 795 | + | </p> | |
| 796 | + | )} | |
| 786 | 797 | <Composer | |
| 787 | 798 | draftKey={data.channel.id} | |
| 788 | 799 | placeholder={withG1t ? "Ask g1t about your work" : placeholder} | |
| โฏ | |||
| 792 | 803 | onTyping={onTyping} | |
| 793 | 804 | autoFocus | |
| 794 | 805 | /> | |
| 806 | + | </> | |
| 795 | 807 | ) : ( | |
| 796 | 808 | <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| 797 | 809 | <p className="text-sm text-muted"> | |
| 798 | โ | You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications. | |
| 810 | + | {data.guest ? ( | |
| 811 | + | <> | |
| 812 | + | You're reading <span className="font-medium text-fg">#{name}</span> as a guest: it's open to anyone on g1t. Join to talk in it; | |
| 813 | + | you'll be marked Guest, since you're not in {data.workspace?.name ?? "the workspace"}. | |
| 814 | + | </> | |
| 815 | + | ) : ( | |
| 816 | + | <> | |
| 817 | + | You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications. | |
| 818 | + | </> | |
| 819 | + | )} | |
| 799 | 820 | </p> | |
| 800 | 821 | <Button | |
| 801 | 822 | type="button" | |
| โฏ | |||
| 844 | 865 | slug={slug} | |
| 845 | 866 | people={people} | |
| 846 | 867 | onAdded={(m) => setMembers((now) => [...now, m])} | |
| 868 | + | onRemoved={(id) => setMembers((now) => now.filter((m) => !(m.member.kind === "user" && m.member.id === id)))} | |
| 847 | 869 | joined={joined} | |
| 848 | 870 | canManage={canManage} | |
| 849 | 871 | onUpdate={updateChannel} | |
| โฏ | |||
| 922 | 944 | {channel.kind === "channel" ? ( | |
| 923 | 945 | <> | |
| 924 | 946 | <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold"> | |
| 925 | โ | {channel.private ? ( | |
| 926 | โ | <Hint label="Private channel: only its members can find and read it"> | |
| 927 | โ | <span className="flex"> | |
| 928 | โ | <Lock size={15} className="text-faint" aria-label="Private channel" /> | |
| 929 | โ | </span> | |
| 930 | โ | </Hint> | |
| 931 | โ | ) : ( | |
| 932 | โ | <Hash size={16} className="text-faint" aria-label="Public channel" /> | |
| 933 | โ | )} | |
| 947 | + | <Hint label={VISIBILITY_HINT[channel.visibility ?? (channel.private ? "private" : "workspace")]}> | |
| 948 | + | <span className="flex text-faint"> | |
| 949 | + | <ChannelMark channel={channel} size={16} /> | |
| 950 | + | </span> | |
| 951 | + | </Hint> | |
| 934 | 952 | <span className="truncate">{channel.name}</span> | |
| 935 | 953 | </h1> | |
| 954 | + | {channel.visibility === "public" && ( | |
| 955 | + | <Hint label="Anyone signed in to g1t can read and join this channel. People outside the workspace are marked Guest."> | |
| 956 | + | <Badge tone="accent" className="shrink-0"> | |
| 957 | + | Public | |
| 958 | + | </Badge> | |
| 959 | + | </Hint> | |
| 960 | + | )} | |
| 961 | + | {data.guest && ( | |
| 962 | + | <Hint label={`You're not a member of ${data.workspace?.name ?? "this workspace"}: you're here as a guest.`}> | |
| 963 | + | <Badge className="shrink-0">Guest</Badge> | |
| 964 | + | </Hint> | |
| 965 | + | )} | |
| 936 | 966 | <StarButton starred={starred} onClick={onStar} /> | |
| 937 | 967 | {muted && ( | |
| 938 | 968 | <Hint label="Muted: no unread count or notifications"> | |
| โฏ | |||
| 985 | 1015 | <Hint label={info ? "Hide details" : "Members and details"}> | |
| 986 | 1016 | <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11"> | |
| 987 | 1017 | <UsersStack count={memberCount} /> | |
| 1018 | + | {(data.requests?.length ?? 0) > 0 && ( | |
| 1019 | + | <span className="ml-1 rounded-full bg-accent px-1.5 text-[0.6875rem] leading-4 font-semibold text-bg" aria-label={`${data.requests!.length} asking to join`}> | |
| 1020 | + | {data.requests!.length} | |
| 1021 | + | </span> | |
| 1022 | + | )} | |
| 988 | 1023 | </Button> | |
| 989 | 1024 | </Hint> | |
| 990 | 1025 | )} | |
| โฏ | |||
| 1345 | 1380 | const author = message.author; | |
| 1346 | 1381 | const time = clock(message.created_at, zone); | |
| 1347 | 1382 | const press = useLongPress(onLongPress); | |
| 1383 | + | // Whose chat this is, for a guest's mark. | |
| 1384 | + | const workspaceName = useChatData()?.sidebar?.workspace?.name ?? null; | |
| 1348 | 1385 | return ( | |
| 1349 | 1386 | <article | |
| 1350 | 1387 | {...press} | |
| โฏ | |||
| 1376 | 1413 | </MemberCard> | |
| 1377 | 1414 | {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />} | |
| 1378 | 1415 | {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />} | |
| 1416 | + | {author.guest && <GuestPill className="self-center" workspace={workspaceName} />} | |
| 1379 | 1417 | <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums"> | |
| 1380 | 1418 | {time} | |
| 1381 | 1419 | </time> | |
| โฏ | |||
| 1629 | 1667 | ); | |
| 1630 | 1668 | } | |
| 1631 | 1669 | ||
| 1670 | + | /** Who can find and read a channel, in a line: its header's hint and its details. */ | |
| 1671 | + | const VISIBILITY_HINT: Record<ChannelVisibility, string> = { | |
| 1672 | + | private: "Private: only the people in it can find and read it", | |
| 1673 | + | workspace: "Workspace: anyone in the workspace can read and join it", | |
| 1674 | + | public: "Public: anyone signed in to g1t can read, join and talk in it", | |
| 1675 | + | }; | |
| 1676 | + | ||
| 1632 | 1677 | /** | |
| 1633 | 1678 | * A channel's name, topic and visibility, and changing them: any member | |
| 1634 | 1679 | * edits the topic; renaming and archiving are for whoever the workspace's | |
| 1635 | โ | * chat settings allow (`canManage`). #general keeps its name and stays. | |
| 1680 | + | * chat settings allow (`canManage`); opening it to anyone on g1t, or | |
| 1681 | + | * keeping it to the workspace again, for the workspace's owners | |
| 1682 | + | * (`canMakePublic`). #general keeps its name and stays. | |
| 1636 | 1683 | */ | |
| 1637 | 1684 | function ChannelAbout({ | |
| 1638 | 1685 | channel, | |
| 1639 | 1686 | joined, | |
| 1640 | 1687 | canManage, | |
| 1688 | + | canMakePublic, | |
| 1689 | + | guest, | |
| 1641 | 1690 | onUpdate, | |
| 1642 | 1691 | }: { | |
| 1643 | 1692 | channel: Channel; | |
| 1644 | 1693 | joined: boolean; | |
| 1645 | 1694 | canManage: boolean; | |
| 1695 | + | canMakePublic: boolean; | |
| 1696 | + | /** The viewer is a guest: they change nothing. */ | |
| 1697 | + | guest: boolean; | |
| 1646 | 1698 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1647 | 1699 | }) { | |
| 1648 | 1700 | const [editing, setEditing] = useState<"name" | "topic" | null>(null); | |
| โฏ | |||
| 1653 | 1705 | const general = channel.name === "general"; | |
| 1654 | 1706 | const archived = !!channel.archived_at; | |
| 1655 | 1707 | const mayRename = canManage && !general && !archived; | |
| 1656 | โ | const mayTopic = joined && !archived; | |
| 1708 | + | const mayTopic = joined && !archived && !guest; | |
| 1709 | + | const visibility: ChannelVisibility = channel.visibility ?? (channel.private ? "private" : "workspace"); | |
| 1657 | 1710 | const save = async (change: ChannelChange) => { | |
| 1658 | 1711 | setBusy(true); | |
| 1659 | 1712 | setError(null); | |
| โฏ | |||
| 1677 | 1730 | }} | |
| 1678 | 1731 | > | |
| 1679 | 1732 | <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim"> | |
| 1680 | โ | {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)} | |
| 1733 | + | {field === "name" && ( | |
| 1734 | + | <span className="flex text-faint"> | |
| 1735 | + | <ChannelMark channel={channel} size={13} /> | |
| 1736 | + | </span> | |
| 1737 | + | )} | |
| 1681 | 1738 | <input | |
| 1682 | 1739 | autoFocus | |
| 1683 | 1740 | value={value} | |
| โฏ | |||
| 1728 | 1785 | </div> | |
| 1729 | 1786 | {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>} | |
| 1730 | 1787 | <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3> | |
| 1731 | โ | <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft"> | |
| 1732 | โ | {channel.private ? <Lock size={13} /> : <Hash size={13} />} | |
| 1733 | โ | {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"} | |
| 1788 | + | <p className="mt-1 flex items-start gap-1.5 text-sm text-fg-soft"> | |
| 1789 | + | <span className="mt-0.5 flex"> | |
| 1790 | + | <ChannelMark channel={channel} size={13} /> | |
| 1791 | + | </span> | |
| 1792 | + | {VISIBILITY_HINT[visibility]} | |
| 1734 | 1793 | </p> | |
| 1794 | + | {canMakePublic && visibility !== "private" && !archived && ( | |
| 1795 | + | <label className="mt-3 flex items-start gap-3 rounded-md bg-bg p-2.5 ring-1 ring-line"> | |
| 1796 | + | <span className="min-w-0 grow"> | |
| 1797 | + | <span className="block text-[0.8125rem] font-medium">Open to anyone on g1t</span> | |
| 1798 | + | <span className="mt-0.5 block text-xs leading-relaxed text-muted"> | |
| 1799 | + | {visibility === "public" | |
| 1800 | + | ? "Turning it off keeps it to the workspace: people outside it who joined on their own leave; guests you asked in stay." | |
| 1801 | + | : "Anyone signed in to g1t can find it by its link, read it and talk in it, marked Guest. They see nothing else of the workspace, and @g1t answers members only."} | |
| 1802 | + | </span> | |
| 1803 | + | </span> | |
| 1804 | + | <Switch | |
| 1805 | + | checked={visibility === "public"} | |
| 1806 | + | disabled={busy} | |
| 1807 | + | onCheckedChange={(open) => void save({ visibility: open ? "public" : "workspace" })} | |
| 1808 | + | aria-label="Open to anyone on g1t" | |
| 1809 | + | /> | |
| 1810 | + | </label> | |
| 1811 | + | )} | |
| 1735 | 1812 | {error && <p className="mt-2 text-xs text-danger">{error}</p>} | |
| 1736 | 1813 | </section> | |
| 1737 | 1814 | </Card> | |
| โฏ | |||
| 1779 | 1856 | slug, | |
| 1780 | 1857 | people, | |
| 1781 | 1858 | onAdded, | |
| 1859 | + | onRemoved, | |
| 1782 | 1860 | joined, | |
| 1783 | 1861 | canManage, | |
| 1784 | 1862 | onUpdate, | |
| โฏ | |||
| 1788 | 1866 | slug: string; | |
| 1789 | 1867 | people: Mentionable[]; | |
| 1790 | 1868 | onAdded: (member: ChannelMember) => void; | |
| 1869 | + | /** Someone taken out of the channel, by their account id. */ | |
| 1870 | + | onRemoved: (id: string) => void; | |
| 1791 | 1871 | joined: boolean; | |
| 1792 | 1872 | /** Whether the viewer may rename, archive and unarchive it. */ | |
| 1793 | 1873 | canManage: boolean; | |
| 1794 | 1874 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1795 | 1875 | }) { | |
| 1796 | 1876 | const channel = data.channel; | |
| 1877 | + | const send = useChatSend(slug); | |
| 1878 | + | const me = useChatData()?.me.id ?? null; | |
| 1879 | + | const workspaceName = data.workspace?.name ?? null; | |
| 1797 | 1880 | const humans = members.filter((m) => m.member.kind === "user"); | |
| 1881 | + | const guests = humans.filter((m) => m.member.guest).length; | |
| 1882 | + | const [requests, setRequests] = useState<ChannelAccessRequest[]>(data.requests ?? []); | |
| 1883 | + | const [invited, setInvited] = useState<ChannelInvitation[]>(data.invited ?? []); | |
| 1884 | + | const [error, setError] = useState<string | null>(null); | |
| 1885 | + | const answer = async (request: ChannelAccessRequest, approve: boolean) => { | |
| 1886 | + | setError(null); | |
| 1887 | + | const done = await send({ intent: "answer_request", channel_id: channel.id, id: request.id, approve }); | |
| 1888 | + | if (!done.ok) return setError(done.error.message); | |
| 1889 | + | setRequests((now) => now.filter((r) => r.id !== request.id)); | |
| 1890 | + | if (approve) { | |
| 1891 | + | onAdded({ channel_id: channel.id, member: request.requester, role: "member", starred: false, muted: false, last_read_id: null, joined_at: new Date().toISOString() }); | |
| 1892 | + | } | |
| 1893 | + | }; | |
| 1894 | + | const takeOut = async (member: MemberProfile) => { | |
| 1895 | + | setError(null); | |
| 1896 | + | const done = await send({ intent: "remove_member", channel_id: channel.id, member: `user:${member.id}` }); | |
| 1897 | + | if (!done.ok) return setError(done.error.message); | |
| 1898 | + | setInvited((now) => now.filter((i) => i.invitee.id !== member.id)); | |
| 1899 | + | onRemoved(member.id); | |
| 1900 | + | }; | |
| 1798 | 1901 | return ( | |
| 1799 | 1902 | <div className="min-h-0 grow overflow-y-auto p-4 scrollbar-thin"> | |
| 1800 | โ | {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />} | |
| 1903 | + | {channel.kind === "channel" && ( | |
| 1904 | + | <ChannelAbout | |
| 1905 | + | channel={channel} | |
| 1906 | + | joined={joined} | |
| 1907 | + | canManage={canManage} | |
| 1908 | + | canMakePublic={data.can_make_public === true} | |
| 1909 | + | guest={data.guest === true} | |
| 1910 | + | onUpdate={onUpdate} | |
| 1911 | + | /> | |
| 1912 | + | )} | |
| 1913 | + | {requests.length > 0 && ( | |
| 1914 | + | <section className="mt-5"> | |
| 1915 | + | <h3 className="mb-1 px-1 text-xs font-medium text-faint">Asking to join ยท {requests.length}</h3> | |
| 1916 | + | <ul className="space-y-1.5"> | |
| 1917 | + | {requests.map((request) => ( | |
| 1918 | + | <li key={request.id} className="rounded-md bg-surface p-2.5 ring-1 ring-line"> | |
| 1919 | + | <div className="flex items-center gap-2"> | |
| 1920 | + | <MemberAvatar member={request.requester} size={22} /> | |
| 1921 | + | <span className="min-w-0 grow truncate text-sm">{shownName(request.requester)}</span> | |
| 1922 | + | {request.requester.guest && <GuestPill workspace={workspaceName} />} | |
| 1923 | + | </div> | |
| 1924 | + | {request.note && <p className="mt-1.5 text-xs leading-relaxed text-muted">{request.note}</p>} | |
| 1925 | + | {request.requester.guest && <p className="mt-1.5 text-xs text-faint">Letting them in makes them a guest here: they see this channel and nothing else of the workspace.</p>} | |
| 1926 | + | <div className="mt-2 flex gap-1.5"> | |
| 1927 | + | <Button type="button" size="xs" variant="accent" onClick={() => void answer(request, true)}> | |
| 1928 | + | Let in | |
| 1929 | + | </Button> | |
| 1930 | + | <Button type="button" size="xs" variant="ghost" onClick={() => void answer(request, false)}> | |
| 1931 | + | Decline | |
| 1932 | + | </Button> | |
| 1933 | + | </div> | |
| 1934 | + | </li> | |
| 1935 | + | ))} | |
| 1936 | + | </ul> | |
| 1937 | + | </section> | |
| 1938 | + | )} | |
| 1801 | 1939 | <section className="mt-5"> | |
| 1802 | 1940 | <div className="mb-1 flex items-center justify-between px-1"> | |
| 1803 | โ | <h3 className="text-xs font-medium text-faint">People ยท {humans.length}</h3> | |
| 1804 | โ | {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />} | |
| 1941 | + | <h3 className="text-xs font-medium text-faint"> | |
| 1942 | + | People ยท {humans.length} | |
| 1943 | + | {guests > 0 && <span className="ml-1">({guests} {guests === 1 ? "guest" : "guests"})</span>} | |
| 1944 | + | </h3> | |
| 1945 | + | {joined && !data.guest && channel.kind === "channel" && ( | |
| 1946 | + | <AddMember | |
| 1947 | + | slug={slug} | |
| 1948 | + | channelId={channel.id} | |
| 1949 | + | people={people} | |
| 1950 | + | members={members} | |
| 1951 | + | guests={data.can_invite_guests === true} | |
| 1952 | + | workspaceName={workspaceName} | |
| 1953 | + | onAdded={onAdded} | |
| 1954 | + | onInvited={(username) => | |
| 1955 | + | setInvited((now) => [ | |
| 1956 | + | ...now, | |
| 1957 | + | { | |
| 1958 | + | id: `pending-${username}`, | |
| 1959 | + | workspace: data.workspace ?? { slug, name: slug, avatar: null }, | |
| 1960 | + | channel, | |
| 1961 | + | invitee: { kind: "user", id: username, name: username, display_name: username, avatar: null, role: null, guest: true }, | |
| 1962 | + | invited_by: { kind: "user", id: me ?? "", name: "you", display_name: "you", avatar: null, role: null }, | |
| 1963 | + | created_at: new Date().toISOString(), | |
| 1964 | + | }, | |
| 1965 | + | ]) | |
| 1966 | + | } | |
| 1967 | + | /> | |
| 1968 | + | )} | |
| 1805 | 1969 | </div> | |
| 1806 | 1970 | <ul className="space-y-px"> | |
| 1807 | 1971 | {humans.map(({ member, role }) => ( | |
| 1808 | โ | <li key={member.id}> | |
| 1972 | + | <li key={member.id} className="group/person relative"> | |
| 1809 | 1973 | <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11"> | |
| 1810 | 1974 | <MemberAvatar member={member} size={26} presence /> | |
| 1811 | 1975 | <span className="min-w-0 grow truncate text-sm"> | |
| โฏ | |||
| 1813 | 1977 | {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>} | |
| 1814 | 1978 | <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert /> | |
| 1815 | 1979 | </span> | |
| 1980 | + | {member.guest && <GuestPill workspace={workspaceName} />} | |
| 1816 | 1981 | {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>} | |
| 1817 | 1982 | </MemberCard> | |
| 1983 | + | {data.can_remove_members && member.id !== me && !(channel.name === "general" && !member.guest) && ( | |
| 1984 | + | <Hint label={`Take ${shownName(member)} out of #${channel.name}`}> | |
| 1985 | + | <Button | |
| 1986 | + | type="button" | |
| 1987 | + | variant="secondary" | |
| 1988 | + | size="icon" | |
| 1989 | + | aria-label={`Take ${shownName(member)} out`} | |
| 1990 | + | onClick={() => void takeOut(member)} | |
| 1991 | + | className="absolute top-1/2 right-1 hidden size-6 -translate-y-1/2 text-faint group-hover/person:flex group-focus-within/person:flex hover:text-fg [@media(hover:none)]:flex" | |
| 1992 | + | > | |
| 1993 | + | <X size={13} /> | |
| 1994 | + | </Button> | |
| 1995 | + | </Hint> | |
| 1996 | + | )} | |
| 1818 | 1997 | </li> | |
| 1819 | 1998 | ))} | |
| 1820 | 1999 | </ul> | |
| 1821 | 2000 | </section> | |
| 2001 | + | {invited.length > 0 && ( | |
| 2002 | + | <section className="mt-5"> | |
| 2003 | + | <h3 className="mb-1 px-1 text-xs font-medium text-faint">Invited as guests ยท {invited.length}</h3> | |
| 2004 | + | <ul className="space-y-px"> | |
| 2005 | + | {invited.map((invitation) => ( | |
| 2006 | + | <li key={invitation.id} className="flex items-center gap-2.5 rounded-md px-1 py-1.5"> | |
| 2007 | + | <MemberAvatar member={invitation.invitee} size={26} /> | |
| 2008 | + | <span className="min-w-0 grow truncate text-sm text-muted"> | |
| 2009 | + | @{invitation.invitee.display_username ?? invitation.invitee.name} | |
| 2010 | + | <span className="ml-1.5 text-xs text-faint">hasn't answered</span> | |
| 2011 | + | </span> | |
| 2012 | + | {data.can_remove_members && !invitation.id.startsWith("pending-") && ( | |
| 2013 | + | <Button type="button" variant="ghost" size="xs" onClick={() => void takeOut(invitation.invitee)}> | |
| 2014 | + | Take back | |
| 2015 | + | </Button> | |
| 2016 | + | )} | |
| 2017 | + | </li> | |
| 2018 | + | ))} | |
| 2019 | + | </ul> | |
| 2020 | + | </section> | |
| 2021 | + | )} | |
| 2022 | + | {error && <p className="mt-3 px-1 text-xs text-danger">{error}</p>} | |
| 1822 | 2023 | </div> | |
| 1823 | 2024 | ); | |
| 1824 | 2025 | } | |
| 1825 | 2026 | ||
| 1826 | โ | /** Adding a person to the channel. An invite lets them read; it never grants write. */ | |
| 2027 | + | /** | |
| 2028 | + | * Adding a person to the channel: someone in the workspace joins at once. | |
| 2029 | + | * With `guests`, anyone else on g1t can be asked in by their username: | |
| 2030 | + | * they are sent an invitation and join as a guest when they accept. | |
| 2031 | + | */ | |
| 1827 | 2032 | function AddMember({ | |
| 1828 | 2033 | slug, | |
| 1829 | 2034 | channelId, | |
| 1830 | 2035 | people, | |
| 1831 | 2036 | members, | |
| 2037 | + | guests, | |
| 2038 | + | workspaceName, | |
| 1832 | 2039 | onAdded, | |
| 2040 | + | onInvited, | |
| 1833 | 2041 | }: { | |
| 1834 | 2042 | slug: string; | |
| 1835 | 2043 | channelId: string; | |
| 1836 | 2044 | people: Mentionable[]; | |
| 1837 | 2045 | members: ChannelMember[]; | |
| 2046 | + | guests: boolean; | |
| 2047 | + | workspaceName: string | null; | |
| 1838 | 2048 | onAdded: (member: ChannelMember) => void; | |
| 2049 | + | onInvited: (username: string) => void; | |
| 1839 | 2050 | }) { | |
| 1840 | 2051 | const [open, setOpen] = useState(false); | |
| 1841 | 2052 | const [query, setQuery] = useState(""); | |
| โฏ | |||
| 1847 | 2058 | .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`)) | |
| 1848 | 2059 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 1849 | 2060 | .slice(0, 8); | |
| 2061 | + | // Someone not in the workspace, by their exact username: asked in as a guest. | |
| 2062 | + | const outsider = guests && /^[a-z0-9][a-z0-9-]*$/.test(q) && !people.some((p) => p.name.toLowerCase() === q) && !inside.has(`user:${q}`) ? q : null; | |
| 2063 | + | const [sent, setSent] = useState<string | null>(null); | |
| 2064 | + | const inviteGuest = async (username: string) => { | |
| 2065 | + | setError(null); | |
| 2066 | + | const done = await send<{ invited: boolean }>({ intent: "invite", channel_id: channelId, member: `user:${username}` }); | |
| 2067 | + | if (!done.ok) return setError(done.error.message); | |
| 2068 | + | if (done.value.invited) { | |
| 2069 | + | onInvited(username); | |
| 2070 | + | setSent(username); | |
| 2071 | + | setQuery(""); | |
| 2072 | + | } else setOpen(false); | |
| 2073 | + | }; | |
| 1850 | 2074 | const add = async (person: Mentionable) => { | |
| 1851 | 2075 | setError(null); | |
| 1852 | 2076 | const done = await send({ intent: "invite", channel_id: channelId, member: `user:${person.name}` }); | |
| โฏ | |||
| 1875 | 2099 | autoFocus | |
| 1876 | 2100 | value={query} | |
| 1877 | 2101 | onChange={(event) => setQuery(event.target.value)} | |
| 1878 | โ | placeholder="Add a person" | |
| 2102 | + | placeholder={guests ? "Add a person, or anyone on g1t by username" : "Add a person"} | |
| 1879 | 2103 | aria-label="Add a person" | |
| 1880 | 2104 | autoComplete="off" | |
| 1881 | 2105 | data-1p-ignore | |
| 1882 | 2106 | className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 1883 | 2107 | /> | |
| 1884 | โ | {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>} | |
| 2108 | + | {options.length === 0 && !outsider && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>} | |
| 1885 | 2109 | {options.map((person) => ( | |
| 1886 | 2110 | <button | |
| 1887 | 2111 | key={`${person.kind}:${person.name}`} | |
| โฏ | |||
| 1893 | 2117 | <span className="min-w-0 grow truncate">{person.display_name}</span> | |
| 1894 | 2118 | </button> | |
| 1895 | 2119 | ))} | |
| 2120 | + | {outsider && ( | |
| 2121 | + | <button | |
| 2122 | + | type="button" | |
| 2123 | + | onClick={() => void inviteGuest(outsider)} | |
| 2124 | + | className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line" | |
| 2125 | + | > | |
| 2126 | + | <UserPlus size={16} className="mt-0.5 shrink-0 text-faint" /> | |
| 2127 | + | <span className="min-w-0 grow"> | |
| 2128 | + | Invite @{outsider} as a guest | |
| 2129 | + | <span className="block text-xs text-faint">Not in {workspaceName ?? "the workspace"}: they join this channel only, once they accept.</span> | |
| 2130 | + | </span> | |
| 2131 | + | </button> | |
| 2132 | + | )} | |
| 2133 | + | {sent && <p className="px-2 pt-1 text-xs text-muted">Invited @{sent}. They'll see it in their Chat sidebar.</p>} | |
| 1896 | 2134 | {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>} | |
| 1897 | 2135 | </PopoverContent> | |
| 1898 | 2136 | </Popover> | |
| 1 | โ | import { Hash, MessagesSquare, PlugZap } from "lucide-react"; | |
| 2 | โ | import type { ReactNode } from "react"; | |
| 1 | + | import { Check, Hash, Lock, MessagesSquare, PlugZap } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useState } from "react"; | |
| 3 | 3 | import { useParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { CreateChannelButton, NewMessageButton } from "./actions"; | |
| 6 | + | import { Button } from "../ui/button"; | |
| 6 | 7 | import { Card } from "../ui/card"; | |
| 8 | + | import { Textarea } from "../ui/textarea"; | |
| 7 | 9 | ||
| 8 | 10 | /** A calm full-height panel for a conversation that cannot be shown. */ | |
| 9 | 11 | function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| โฏ | |||
| 45 | 47 | export function ChannelGlyph({ className }: { className?: string }) { | |
| 46 | 48 | return <Hash size={16} className={className} />; | |
| 47 | 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 | + | } | |
| 3 | 3 | ||
| 4 | 4 | import { cn } from "../../lib/cn"; | |
| 5 | 5 | import { Avatar } from "../ui/avatar"; | |
| 6 | + | import { Hint } from "../ui/hint"; | |
| 6 | 7 | ||
| 7 | 8 | /** | |
| 8 | 9 | * A member's face: a person's avatar, or, on an old message from an agent | |
| โฏ | |||
| 51 | 52 | </span> | |
| 52 | 53 | ); | |
| 53 | 54 | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * GUEST beside the name of someone who is not a member of the workspace | |
| 58 | + | * whose chat this is: asked into a channel, or talking in a public one. | |
| 59 | + | * Wherever people show (messages, members, mentions), so who is in the | |
| 60 | + | * workspace is never in doubt. | |
| 61 | + | */ | |
| 62 | + | export function GuestPill({ className, workspace }: { className?: string; workspace?: string | null }) { | |
| 63 | + | return ( | |
| 64 | + | <Hint label={`Not a member of ${workspace ?? "this workspace"}`}> | |
| 65 | + | <span | |
| 66 | + | className={cn( | |
| 67 | + | "inline-flex h-4 shrink-0 items-center rounded-sm border border-line-strong px-1 text-[0.5625rem] font-semibold tracking-[0.06em] text-muted uppercase", | |
| 68 | + | className, | |
| 69 | + | )} | |
| 70 | + | > | |
| 71 | + | Guest | |
| 72 | + | </span> | |
| 73 | + | </Hint> | |
| 74 | + | ); | |
| 75 | + | } | |
| 1 | โ | import { BellOff, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Search } from "lucide-react"; | |
| 1 | + | import { ArrowLeft, BellOff, Check, ChevronDown, Compass, Globe, Hash, Lock, Pin, PinOff, Search, X } from "lucide-react"; | |
| 2 | 2 | import { ContextMenu } from "radix-ui"; | |
| 3 | 3 | import { type ReactNode, useEffect, useState } from "react"; | |
| 4 | 4 | import { NavLink, useFetcher, useLocation, useNavigate } from "react-router"; | |
| 5 | 5 | ||
| 6 | โ | import type { ChatSidebarEntry } from "@g1t/contracts"; | |
| 6 | + | import type { Channel, ChannelInvitation, ChatSidebarEntry, GuestChannel } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import { conversationCache } from "./conversation-cache"; | |
| 9 | 9 | import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 10 | 10 | import { DndMenu } from "./dnd-menu"; | |
| 11 | 11 | import { MemberAvatar } from "./marks"; | |
| 12 | 12 | import { PersonStatusEmoji } from "../presence"; | |
| 13 | + | import { Avatar } from "../ui/avatar"; | |
| 14 | + | import { Badge } from "../ui/badge"; | |
| 13 | 15 | import { Button } from "../ui/button"; | |
| 14 | 16 | import { Hint } from "../ui/hint"; | |
| 15 | 17 | import { Skeleton } from "../ui/skeleton"; | |
| โฏ | |||
| 46 | 48 | * stay folded on this device. All, Unread and Mentions filter every section. It refreshes | |
| 47 | 49 | * itself while the tab is shown, so unread counts move without a reload. | |
| 48 | 50 | */ | |
| 49 | โ | export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) { | |
| 51 | + | export function ChatSidebar({ slug: home, heading = true }: { slug: string; heading?: boolean }) { | |
| 50 | 52 | const data = useChatData(); | |
| 53 | + | // The workspace whose chat is open: another one's, for a guest there. | |
| 54 | + | const slug = data?.slug ?? home; | |
| 51 | 55 | const { sidebar, refresh } = useChatSidebar(); | |
| 52 | 56 | const send = useChatSend(slug); | |
| 53 | 57 | const [filter, setFilter] = useState<ChatFilter>("all"); | |
| โฏ | |||
| 127 | 131 | </div> | |
| 128 | 132 | </div> | |
| 129 | 133 | )} | |
| 134 | + | {sidebar?.guest && sidebar.workspace && <GuestBanner workspace={sidebar.workspace} home={home} />} | |
| 130 | 135 | <div className="space-y-2.5 px-2.5 pt-3"> | |
| 131 | 136 | <div className="flex items-center gap-2"> | |
| 132 | 137 | <label className="flex h-8 min-w-0 grow items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70"> | |
| โฏ | |||
| 191 | 196 | title="Channels" | |
| 192 | 197 | folded={folded} | |
| 193 | 198 | onFold={fold} | |
| 194 | โ | action={<CreateChannelButton slug={slug} />} | |
| 199 | + | action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />} | |
| 195 | 200 | keep={channels.filter(keepShown).map(conversation)} | |
| 196 | 201 | > | |
| 197 | 202 | {channels.map(conversation)} | |
| 198 | โ | {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>} | |
| 203 | + | {channels.length === 0 && filter === "all" && !query && <Empty>{sidebar.guest ? "You're in none of its channels yet." : "No channels yet."}</Empty>} | |
| 199 | 204 | {filter === "all" && !query && ( | |
| 200 | 205 | <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join"> | |
| 201 | โ | Browse channels | |
| 206 | + | {sidebar.guest ? "Public channels" : "Browse channels"} | |
| 202 | 207 | </ExtraRow> | |
| 203 | 208 | )} | |
| 204 | 209 | </Section> | |
| 205 | โ | <Section | |
| 210 | + | {(sidebar.invitations?.length ?? 0) > 0 && filter === "all" && !query && ( | |
| 211 | + | <Section id="invitations" title="Invitations" folded={folded} onFold={fold}> | |
| 212 | + | {sidebar.invitations!.map((invitation) => ( | |
| 213 | + | <InvitationRow key={invitation.id} invitation={invitation} onAnswered={refresh} /> | |
| 214 | + | ))} | |
| 215 | + | </Section> | |
| 216 | + | )} | |
| 217 | + | {(sidebar.elsewhere?.length ?? 0) > 0 && ( | |
| 218 | + | <Section id="elsewhere" title="Other workspaces" folded={folded} onFold={fold}> | |
| 219 | + | {sidebar.elsewhere! | |
| 220 | + | .filter((entry) => (filter === "all" || (filter === "unread" ? entry.unread > 0 : entry.mentions > 0)) && (!query || entry.channel.name?.includes(query.toLowerCase()))) | |
| 221 | + | .map((entry) => ( | |
| 222 | + | <ElsewhereRow key={entry.channel.id} entry={entry} pathname={pathname} /> | |
| 223 | + | ))} | |
| 224 | + | </Section> | |
| 225 | + | )} | |
| 226 | + | {/* A guest has no direct messages here: those are for the workspace's members. */} | |
| 227 | + | {!sidebar.guest && (<Section | |
| 206 | 228 | id="people" | |
| 207 | 229 | title="Direct messages" | |
| 208 | 230 | folded={folded} | |
| โฏ | |||
| 218 | 240 | )} | |
| 219 | 241 | {dms.map(conversation)} | |
| 220 | 242 | {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>} | |
| 221 | โ | </Section> | |
| 243 | + | </Section>)} | |
| 222 | 244 | {shown.length === 0 && (filter !== "all" || query) && ( | |
| 223 | 245 | <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p> | |
| 224 | 246 | )} | |
| โฏ | |||
| 406 | 428 | entry.channel.kind === "dm" && other ? ( | |
| 407 | 429 | // One person: their dot, cut out of the sidebar behind it. | |
| 408 | 430 | <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" /> | |
| 409 | โ | ) : entry.channel.private ? ( | |
| 410 | โ | <Lock size={14} /> | |
| 411 | 431 | ) : ( | |
| 412 | โ | <Hash size={15} /> | |
| 432 | + | <ChannelMark channel={entry.channel} /> | |
| 413 | 433 | ); | |
| 414 | 434 | return ( | |
| 415 | 435 | <li className="group/row relative"> | |
| โฏ | |||
| 438 | 458 | ); | |
| 439 | 459 | } | |
| 440 | 460 | ||
| 461 | + | /** A channel's mark: a lock when private, a globe when open to anyone on g1t, else a hash. */ | |
| 462 | + | export function ChannelMark({ channel, size = 15 }: { channel: Pick<Channel, "private" | "visibility">; size?: number }) { | |
| 463 | + | if (channel.private || channel.visibility === "private") return <Lock size={size - 1} aria-label="Private channel" />; | |
| 464 | + | if (channel.visibility === "public") return <Globe size={size - 1} aria-label="Public channel, open to anyone on g1t" />; | |
| 465 | + | return <Hash size={size} aria-label="Workspace channel" />; | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /** | |
| 469 | + | * At the top of a guest's sidebar: whose chat this is, that they are not | |
| 470 | + | * in the workspace, and the way back to their own. | |
| 471 | + | */ | |
| 472 | + | function GuestBanner({ workspace, home }: { workspace: { slug: string; name: string; avatar: string | null }; home: string }) { | |
| 473 | + | const own = home.toLowerCase() !== workspace.slug; | |
| 474 | + | return ( | |
| 475 | + | <div className="mx-2.5 mt-1 rounded-lg bg-surface p-2.5 ring-1 ring-line"> | |
| 476 | + | <div className="flex items-center gap-2"> | |
| 477 | + | <Avatar name={workspace.slug} image={workspace.avatar} size={20} square /> | |
| 478 | + | <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{workspace.name}</span> | |
| 479 | + | <Badge>Guest</Badge> | |
| 480 | + | </div> | |
| 481 | + | <p className="mt-1.5 text-xs leading-relaxed text-muted"> | |
| 482 | + | You're not a member of this workspace. You see only the channels you're in and its public ones. | |
| 483 | + | </p> | |
| 484 | + | {own && ( | |
| 485 | + | <NavLink to={`/${home}/-/chat`} className="mt-2 inline-flex items-center gap-1 text-xs text-accent hover:underline"> | |
| 486 | + | <ArrowLeft size={12} /> | |
| 487 | + | Back to your chat | |
| 488 | + | </NavLink> | |
| 489 | + | )} | |
| 490 | + | </div> | |
| 491 | + | ); | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | /** A channel of another workspace the viewer is a guest in: its workspace's icon, its name, its count. */ | |
| 495 | + | function ElsewhereRow({ entry, pathname }: { entry: GuestChannel; pathname: string }) { | |
| 496 | + | const to = `/${entry.workspace.slug}/-/chat/${entry.channel.name}`; | |
| 497 | + | const current = pathname === to; | |
| 498 | + | const count = entry.mentions > 0 ? entry.mentions : entry.unread; | |
| 499 | + | return ( | |
| 500 | + | <li> | |
| 501 | + | <NavLink | |
| 502 | + | to={to} | |
| 503 | + | aria-current={current ? "page" : undefined} | |
| 504 | + | className={`${ROW} ${current ? "bg-sidebar-accent text-fg" : count > 0 ? "text-fg hover:bg-sidebar-accent/60" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`} | |
| 505 | + | > | |
| 506 | + | <span className="flex w-4.5 shrink-0 justify-center"> | |
| 507 | + | <Avatar name={entry.workspace.slug} image={entry.workspace.avatar} size={16} square /> | |
| 508 | + | </span> | |
| 509 | + | <span className={`min-w-0 grow truncate ${count > 0 ? "font-semibold" : ""}`}> | |
| 510 | + | #{entry.channel.name} | |
| 511 | + | <span className="ml-1.5 text-xs font-normal text-faint">{entry.workspace.name}</span> | |
| 512 | + | </span> | |
| 513 | + | {count > 0 && ( | |
| 514 | + | <span className={`min-w-5 shrink-0 rounded-full px-1.5 text-center text-[0.6875rem] leading-4.5 font-semibold tabular-nums ${entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}> | |
| 515 | + | {count} | |
| 516 | + | </span> | |
| 517 | + | )} | |
| 518 | + | </NavLink> | |
| 519 | + | </li> | |
| 520 | + | ); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /** An invitation into another workspace's channel: who asked, and Join or Decline. */ | |
| 524 | + | function InvitationRow({ invitation, onAnswered }: { invitation: ChannelInvitation; onAnswered: () => void }) { | |
| 525 | + | const navigate = useNavigate(); | |
| 526 | + | const [busy, setBusy] = useState(false); | |
| 527 | + | const [error, setError] = useState<string | null>(null); | |
| 528 | + | const answer = async (accept: boolean) => { | |
| 529 | + | setBusy(true); | |
| 530 | + | setError(null); | |
| 531 | + | try { | |
| 532 | + | const response = await fetch(`/${invitation.workspace.slug}/-/chat/api`, { | |
| 533 | + | method: "POST", | |
| 534 | + | headers: { "content-type": "application/json" }, | |
| 535 | + | body: JSON.stringify({ intent: "answer_invitation", id: invitation.id, accept }), | |
| 536 | + | }); | |
| 537 | + | const result = (await response.json()) as { ok: boolean; value?: { to: string | null }; error?: { message: string } }; | |
| 538 | + | if (!result.ok) { | |
| 539 | + | setError(result.error?.message ?? "That didn't work."); | |
| 540 | + | return; | |
| 541 | + | } | |
| 542 | + | onAnswered(); | |
| 543 | + | if (accept && result.value?.to) navigate(result.value.to); | |
| 544 | + | } catch { | |
| 545 | + | setError("Couldn't reach g1t. Try again."); | |
| 546 | + | } finally { | |
| 547 | + | setBusy(false); | |
| 548 | + | } | |
| 549 | + | }; | |
| 550 | + | return ( | |
| 551 | + | <li className="rounded-md px-2 py-1.5 text-[0.8125rem]"> | |
| 552 | + | <div className="flex items-center gap-2"> | |
| 553 | + | <Avatar name={invitation.workspace.slug} image={invitation.workspace.avatar} size={16} square /> | |
| 554 | + | <span className="min-w-0 grow truncate"> | |
| 555 | + | #{invitation.channel.name} <span className="text-xs text-faint">{invitation.workspace.name}</span> | |
| 556 | + | </span> | |
| 557 | + | </div> | |
| 558 | + | <p className="mt-0.5 pl-6 text-xs text-faint">Asked in as a guest by @{invitation.invited_by.display_username ?? invitation.invited_by.name}</p> | |
| 559 | + | <div className="mt-1.5 flex gap-1.5 pl-6"> | |
| 560 | + | <Button type="button" size="xs" disabled={busy} onClick={() => void answer(true)}> | |
| 561 | + | <Check size={12} /> | |
| 562 | + | Join | |
| 563 | + | </Button> | |
| 564 | + | <Button type="button" size="xs" variant="ghost" disabled={busy} onClick={() => void answer(false)}> | |
| 565 | + | <X size={12} /> | |
| 566 | + | Decline | |
| 567 | + | </Button> | |
| 568 | + | </div> | |
| 569 | + | {error && <p className="mt-1 pl-6 text-xs text-danger">{error}</p>} | |
| 570 | + | </li> | |
| 571 | + | ); | |
| 572 | + | } | |
| 573 | + | ||
| 441 | 574 | function SidebarSkeleton() { | |
| 442 | 575 | return ( | |
| 443 | 576 | <div className="space-y-2 px-2 pt-1" aria-busy="true"> | |
| 49 | 49 | return people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id })); | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | /** | |
| 53 | + | * A person anywhere on g1t, by `user:<username>` (or a bare username), as | |
| 54 | + | * the chat service names them: by their account id. Null for no one, and | |
| 55 | + | * for anything that is not a person's account. | |
| 56 | + | */ | |
| 57 | + | export async function personFrom(field: string): Promise<Principal | null> { | |
| 58 | + | const name = field.replace(/^user:/, "").replace(/^@/, "").trim().toLowerCase(); | |
| 59 | + | if (!name || name.startsWith("usr_")) return null; | |
| 60 | + | const user = await identity.userByUsername(name).catch(() => null); | |
| 61 | + | return user && (user.kind ?? "user") === "user" ? { kind: "user", id: user.id } : null; | |
| 62 | + | } | |
| 63 | + | ||
| 52 | 64 | /** The cookie that remembers the last conversation open, so Chat opens there again. */ | |
| 53 | 65 | export const LAST_CHAT_COOKIE = "g1t_chat"; | |
| 54 | 66 |
| 110 | 110 | assert.equal(modeOf("/acme/-/workspace", "acme"), "workspace"); | |
| 111 | 111 | assert.equal(modeOf("/acme/web/pulls", "acme"), "code"); | |
| 112 | 112 | assert.equal(modeOf("/new", "acme"), "code"); | |
| 113 | โ | // Another workspace's chat is not this one's mode; its repositories are Code's. | |
| 114 | โ | assert.equal(modeOf("/other/-/chat", "acme"), "code"); | |
| 113 | + | // Another workspace's chat is Chat (a guest in its channels); its repositories are Code's. | |
| 114 | + | assert.equal(modeOf("/other/-/chat", "acme"), "chat"); | |
| 115 | + | assert.equal(modeOf("/other/web", "acme"), "code"); | |
| 115 | 116 | assert.equal(modeHome("home", "acme"), "/acme/-/home"); | |
| 116 | 117 | assert.equal(modeHome("notifications", "acme"), "/notifications"); | |
| 117 | 118 | assert.equal(modeHome("people", "acme"), "/acme/-/people"); | |
| โฏ | |||
| 195 | 196 | assert.equal(shellMode("/acme/web", viewer), "code"); | |
| 196 | 197 | assert.equal(shellMode("/friend/notes", viewer), "code"); | |
| 197 | 198 | assert.equal(shellMode("/acme/-/chat", viewer), "chat"); | |
| 199 | + | // Another workspace's channels, where you are a guest, are Chat's, not browsed. | |
| 200 | + | assert.equal(browsingOf("/flagon-io/-/chat/community-support", viewer), null); | |
| 201 | + | assert.equal(shellMode("/flagon-io/-/chat/community-support", viewer), "chat"); | |
| 202 | + | assert.equal(shellMode("/flagon-io/-/chat", viewer), "chat"); | |
| 198 | 203 | assert.equal(shellMode("/policies", viewer), "site"); | |
| 199 | 204 | }); | |
| 291 | 291 | return PAGE_MODES[parts[2] ?? ""] ?? "code"; | |
| 292 | 292 | } | |
| 293 | 293 | } | |
| 294 | + | // Another workspace's chat, where the viewer is a guest: Chat all the same. | |
| 295 | + | if (parts[1] === "-" && parts[2] === "chat") return "chat"; | |
| 294 | 296 | return "code"; | |
| 295 | 297 | } | |
| 296 | 298 | ||
| โฏ | |||
| 372 | 374 | // g1t's own pages, and files such as robots.txt. | |
| 373 | 375 | if (NOT_AN_OWNER.has(first) || first.includes(".")) return null; | |
| 374 | 376 | if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null; | |
| 377 | + | // Another workspace's channels, as a guest in them, are Chat's (`modeOf`), not Explore's. | |
| 378 | + | if (parts[1] === "-" && parts[2] === "chat") return null; | |
| 375 | 379 | const repo = parts[1] && parts[1] !== "-" ? parts[1] : null; | |
| 376 | 380 | if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null; | |
| 377 | 381 | return { owner: { kind: "workspace", name: parts[0]!, repo } }; | |
| 91 | 91 | <section className={SECTION}> | |
| 92 | 92 | <h2 className="font-medium">Creating channels</h2> | |
| 93 | 93 | <p className="mt-1.5 text-xs text-faint"> | |
| 94 | โ | Today {settings.public_channels === "owners" ? "only owners" : "any member"} can create a public channel and{" "} | |
| 94 | + | Today {settings.public_channels === "owners" ? "only owners" : "any member"} can create a workspace channel and{" "} | |
| 95 | 95 | {settings.private_channels === "owners" ? "only owners" : "any member"} a private one.{" "} | |
| 96 | 96 | {owner ? ( | |
| 97 | 97 | <> | |
| โฏ | |||
| 156 | 156 | <section className={SECTION}> | |
| 157 | 157 | <h2 className="font-medium">Default channels</h2> | |
| 158 | 158 | <p className="mt-1.5 text-xs text-faint"> | |
| 159 | โ | The public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not | |
| 159 | + | The workspace and public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not | |
| 160 | 160 | moved. | |
| 161 | 161 | </p> | |
| 162 | 162 | {view.channels.length === 0 ? ( | |
| 163 | โ | <p className="mt-5 text-sm text-muted">No public channels yet.</p> | |
| 163 | + | <p className="mt-5 text-sm text-muted">No workspace or public channels yet.</p> | |
| 164 | 164 | ) : ( | |
| 165 | 165 | <ul className="mt-5 grid gap-2.5 sm:grid-cols-2"> | |
| 166 | 166 | {view.channels.map((channel) => ( | |
| 1 | โ | import { data } from "react-router"; | |
| 2 | โ | ||
| 3 | 1 | import { type AskPage, type Result, parsePrincipalKey } from "@g1t/contracts"; | |
| 4 | 2 | ||
| 5 | 3 | import type { Route } from "./+types/api"; | |
| 6 | 4 | import { RESERVED_CHANNEL_NAMES, channelName, channelPath } from "../../../lib/chat"; | |
| 7 | โ | import { principalsFrom } from "../../../lib/chat.server"; | |
| 5 | + | import { personFrom, principalsFrom } from "../../../lib/chat.server"; | |
| 8 | 6 | import { chat } from "../../../lib/services.server"; | |
| 9 | โ | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 7 | + | import { assertSameOrigin, requireUser } from "../../../lib/session.server"; | |
| 10 | 8 | ||
| 11 | 9 | /** | |
| 12 | 10 | * What a chat page asks for as it runs, as JSON, without loading a page: | |
| โฏ | |||
| 15 | 13 | * sends: messages, read marks, stars, new channels and direct messages, | |
| 16 | 14 | * and the actions pressed on cards. | |
| 17 | 15 | * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services' | |
| 18 | โ | * own shape. | |
| 16 | + | * own shape. Guests (people outside the workspace) reach it too: the chat | |
| 17 | + | * service answers them with only what they may see and do. | |
| 19 | 18 | */ | |
| 20 | 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 21 | 20 | const viewer = requireUser(context, request); | |
| 22 | โ | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 23 | 21 | const slug = params.owner.toLowerCase(); | |
| 24 | 22 | const url = new URL(request.url); | |
| 25 | 23 | const channel = url.searchParams.get("channel"); | |
| โฏ | |||
| 57 | 55 | archived?: boolean; | |
| 58 | 56 | /** A question for @g1t asked from a page (the top bar's Ask): where they were. */ | |
| 59 | 57 | page?: unknown; | |
| 58 | + | /** `workspace` or `public`: who besides its members may open a channel. */ | |
| 59 | + | visibility?: string; | |
| 60 | + | /** Accepting or declining an invitation; letting someone in on a request. */ | |
| 61 | + | accept?: boolean; | |
| 62 | + | approve?: boolean; | |
| 63 | + | /** A channel's name, asking to be let in; and what to tell whoever answers. */ | |
| 64 | + | note?: string; | |
| 60 | 65 | }; | |
| 61 | 66 | ||
| 62 | 67 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 63 | 68 | assertSameOrigin(request); | |
| 64 | 69 | const viewer = requireUser(context, request); | |
| 65 | โ | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 66 | 70 | const slug = params.owner.toLowerCase(); | |
| 67 | 71 | const sent = (await request.json().catch(() => ({}))) as Sent; | |
| 68 | 72 | const channel = sent.channel_id ?? ""; | |
| โฏ | |||
| 106 | 110 | case "leave": | |
| 107 | 111 | return chat.leave(slug, channel, viewer); | |
| 108 | 112 | case "invite": { | |
| 109 | โ | const [member] = await principalsFrom(slug, viewer, [sent.member ?? ""]); | |
| 110 | โ | // A person by username, or by the account id chat names them with; only people are added. | |
| 113 | + | // A person by username (`user:<username>`), in the workspace or not: anyone else is | |
| 114 | + | // sent an invitation to join as a guest; or by the account id chat names them with. | |
| 111 | 115 | const parsed = parsePrincipalKey(sent.member ?? ""); | |
| 112 | โ | const principal = member ?? (parsed?.kind === "user" ? parsed : null); | |
| 113 | โ | if (!principal) return { ok: false, error: { code: "invalid", message: "Choose someone to add." } }; | |
| 116 | + | const principal = (await personFrom(sent.member ?? "")) ?? (parsed?.kind === "user" && parsed.id.startsWith("usr_") ? parsed : null); | |
| 117 | + | if (!principal) return { ok: false, error: { code: "not_found", message: "There's no one on g1t by that name." } }; | |
| 114 | 118 | return chat.invite(slug, channel, viewer, principal); | |
| 115 | 119 | } | |
| 120 | + | // Takes someone out of the channel, or takes back their invitation. | |
| 121 | + | case "remove_member": { | |
| 122 | + | const parsed = parsePrincipalKey(sent.member ?? ""); | |
| 123 | + | if (!parsed || parsed.kind !== "user") return { ok: false, error: { code: "invalid", message: "Choose someone to take out." } }; | |
| 124 | + | return chat.removeMember(slug, channel, viewer, parsed); | |
| 125 | + | } | |
| 126 | + | case "answer_invitation": | |
| 127 | + | return chat.answerChannelInvitation(viewer, sent.id ?? "", sent.accept === true); | |
| 128 | + | // Asking to be let into a channel by the name in its link; the answer is the same whatever there is. | |
| 129 | + | case "request_access": | |
| 130 | + | return chat.requestAccess(slug, sent.name ?? "", viewer, typeof sent.note === "string" ? sent.note : null); | |
| 131 | + | case "answer_request": | |
| 132 | + | return chat.answerAccessRequest(slug, channel, viewer, sent.id ?? "", sent.approve === true); | |
| 116 | 133 | case "create_channel": { | |
| 117 | 134 | const name = channelName(sent.name ?? ""); | |
| 118 | 135 | if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } }; | |
| 119 | 136 | if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) { | |
| 120 | 137 | return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } }; | |
| 121 | 138 | } | |
| 122 | โ | const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) }); | |
| 139 | + | const visibility = sent.visibility === "public" || sent.visibility === "workspace" || sent.visibility === "private" ? sent.visibility : undefined; | |
| 140 | + | const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private), visibility }); | |
| 123 | 141 | return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made; | |
| 124 | 142 | } | |
| 125 | 143 | case "update_channel": { | |
| 126 | โ | // A rename, a new topic, archiving or unarchiving; the chat service checks who may. | |
| 127 | โ | const change: { name?: string; topic?: string | null; archived?: boolean } = {}; | |
| 144 | + | // A rename, a new topic, archiving or unarchiving, or who may open it; the chat service checks who may. | |
| 145 | + | const change: { name?: string; topic?: string | null; archived?: boolean; visibility?: "workspace" | "public" } = {}; | |
| 128 | 146 | if (typeof sent.name === "string") { | |
| 129 | 147 | const name = channelName(sent.name); | |
| 130 | 148 | if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } }; | |
| โฏ | |||
| 135 | 153 | } | |
| 136 | 154 | if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null; | |
| 137 | 155 | if (typeof sent.archived === "boolean") change.archived = sent.archived; | |
| 156 | + | if (sent.visibility === "workspace" || sent.visibility === "public") change.visibility = sent.visibility; | |
| 138 | 157 | const updated = await chat.updateChannel(slug, channel, viewer, change); | |
| 139 | 158 | return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated; | |
| 140 | 159 | } | |
| 1 | โ | import { Archive, Hash, Lock, Search } from "lucide-react"; | |
| 1 | + | import { Archive, Hash, Search } from "lucide-react"; | |
| 2 | 2 | import { useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, useNavigate } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/browse"; | |
| 6 | 6 | import { ChatUnavailable } from "../../../components/chat/empty"; | |
| 7 | + | import { ChannelMark } from "../../../components/chat/sidebar"; | |
| 7 | 8 | import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions"; | |
| 8 | 9 | import { channelPath } from "../../../lib/chat"; | |
| 9 | 10 | import { sidebarOrNull } from "../../../lib/chat.server"; | |
| โฏ | |||
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | /** | |
| 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. | |
| 22 | + | * Every channel the viewer may join, joined or not: for a member, those | |
| 23 | + | * kept to the workspace and the public ones; for a guest, the public ones | |
| 24 | + | * only; and the private ones the viewer is in (the chat service leaves | |
| 25 | + | * out the rest). With `?archived=1`, the archived ones instead. | |
| 24 | 26 | */ | |
| 25 | 27 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | 28 | const viewer = requireUser(context, request); | |
| โฏ | |||
| 29 | 31 | const [listed, sidebar] = await Promise.all([chat.browse(slug, viewer, { archived }).catch(() => null), sidebarOrNull(slug, viewer)]); | |
| 30 | 32 | if (!listed?.ok) return { slug, unavailable: true as const }; | |
| 31 | 33 | const joined = (sidebar?.entries ?? []).map((entry) => entry.channel.id); | |
| 32 | โ | return { slug, unavailable: false as const, archived, channels: listed.value, joined }; | |
| 34 | + | return { slug, unavailable: false as const, archived, channels: listed.value, joined, guest: sidebar?.guest === true, name: sidebar?.workspace?.name ?? slug }; | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | export default function Browse({ loaderData }: Route.ComponentProps) { | |
| โฏ | |||
| 49 | 51 | <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12"> | |
| 50 | 52 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 51 | 53 | <div> | |
| 52 | โ | <h1 className="text-2xl font-semibold tracking-tight">{loaderData.archived ? "Archived channels" : "Browse channels"}</h1> | |
| 54 | + | <h1 className="text-2xl font-semibold tracking-tight"> | |
| 55 | + | {loaderData.archived ? "Archived channels" : loaderData.guest ? `Public channels in ${loaderData.name}` : "Browse channels"} | |
| 56 | + | </h1> | |
| 53 | 57 | <p className="mt-1 text-sm text-muted"> | |
| 54 | 58 | {loaderData.archived | |
| 55 | 59 | ? "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.`} | |
| 60 | + | : loaderData.guest | |
| 61 | + | ? `You're not a member of ${loaderData.name}. These channels are open to anyone on g1t: read them, join them and talk in them as a guest.` | |
| 62 | + | : `Every channel in ${loaderData.name} anyone in the workspace can read and join, its public ones open to anyone on g1t, and the private ones you're in.`} | |
| 57 | 63 | </p> | |
| 58 | โ | {!loaderData.archived && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>} | |
| 64 | + | {!loaderData.archived && !loaderData.guest && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>} | |
| 59 | 65 | </div> | |
| 60 | 66 | <div className="flex items-center gap-2"> | |
| 61 | 67 | <Link | |
| โฏ | |||
| 65 | 71 | {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />} | |
| 66 | 72 | {loaderData.archived ? "Current channels" : "Archived"} | |
| 67 | 73 | </Link> | |
| 68 | โ | {!loaderData.archived && <CreateChannelButton slug={loaderData.slug} variant="button" />} | |
| 74 | + | {!loaderData.archived && !loaderData.guest && <CreateChannelButton slug={loaderData.slug} variant="button" />} | |
| 69 | 75 | </div> | |
| 70 | 76 | </div> | |
| 71 | 77 | <Card asChild radius="lg" className="mt-6 flex h-10 items-center gap-2 px-3 text-sm focus-within:border-accent-dim"> | |
| โฏ | |||
| 97 | 103 | key={channel.id} | |
| 98 | 104 | 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 | 105 | > | |
| 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 | โ | )} | |
| 106 | + | <span className="flex w-4 shrink-0 justify-center text-faint"> | |
| 107 | + | <ChannelMark channel={channel} size={16} /> | |
| 108 | + | </span> | |
| 105 | 109 | <div className="min-w-0 grow"> | |
| 106 | 110 | <Link | |
| 107 | 111 | to={channelPath(loaderData.slug, channel)} | |
| โฏ | |||
| 109 | 113 | > | |
| 110 | 114 | {channel.name} | |
| 111 | 115 | </Link> | |
| 112 | โ | {channel.private && <span className="ml-2 text-xs text-faint">Private</span>} | |
| 116 | + | {channel.visibility !== "workspace" && ( | |
| 117 | + | <span className="ml-2 text-xs text-faint">{channel.visibility === "public" ? "Public ยท anyone on g1t" : "Private"}</span> | |
| 118 | + | )} | |
| 113 | 119 | {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>} | |
| 114 | 120 | </div> | |
| 115 | 121 | {loaderData.archived ? ( | |
| 2 | 2 | import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | ||
| 5 | โ | import type { Channel, ChannelMember, ChatMessage, User } from "@g1t/contracts"; | |
| 5 | + | import type { Channel, ChannelAccessRequest, ChannelDetail, ChannelInvitation, ChannelMember, ChatMessage, User } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/channel"; | |
| 8 | 8 | import { ChannelView } from "../../../components/chat/channel"; | |
| 9 | 9 | import { conversationCache } from "../../../components/chat/conversation-cache"; | |
| 10 | โ | import { ChatUnavailable } from "../../../components/chat/empty"; | |
| 10 | + | import { ChatUnavailable, RequestAccess } from "../../../components/chat/empty"; | |
| 11 | 11 | import { channelPath } from "../../../lib/chat"; | |
| 12 | 12 | import { rememberChat } from "../../../lib/chat.server"; | |
| 13 | 13 | import { page } from "../../../lib/meta"; | |
| 14 | โ | import { chat } from "../../../lib/services.server"; | |
| 14 | + | import { chat, identity } from "../../../lib/services.server"; | |
| 15 | 15 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 16 | 16 | ||
| 17 | 17 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| โฏ | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | export type ChannelData = | |
| 23 | โ | | { unavailable: true; title: string } | |
| 24 | 23 | | { | |
| 24 | + | unavailable: true; | |
| 25 | + | title: string; | |
| 26 | + | /** | |
| 27 | + | * A channel by name the viewer cannot open: private, kept to a | |
| 28 | + | * workspace they are not in, or not there at all (which looks the | |
| 29 | + | * same). They may ask to be let in. | |
| 30 | + | */ | |
| 31 | + | locked?: { name: string; workspace: { slug: string; name: string }; member: boolean }; | |
| 32 | + | } | |
| 33 | + | | { | |
| 25 | 34 | unavailable: false; | |
| 26 | 35 | title: string; | |
| 27 | 36 | channel: Channel; | |
| โฏ | |||
| 32 | 41 | joined: boolean; | |
| 33 | 42 | /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */ | |
| 34 | 43 | can_manage?: boolean; | |
| 44 | + | /** What else the viewer may do there, and who is asked in or asks to come in (`ChannelDetail`). */ | |
| 45 | + | can_make_public?: boolean; | |
| 46 | + | can_invite_guests?: boolean; | |
| 47 | + | can_remove_members?: boolean; | |
| 48 | + | /** The viewer is not a member of the workspace: a guest here. */ | |
| 49 | + | guest?: boolean; | |
| 50 | + | workspace?: ChannelDetail["workspace"]; | |
| 51 | + | invited?: ChannelInvitation[]; | |
| 52 | + | requests?: ChannelAccessRequest[]; | |
| 35 | 53 | }; | |
| 36 | 54 | ||
| 37 | 55 | /** | |
| โฏ | |||
| 41 | 59 | */ | |
| 42 | 60 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 43 | 61 | const viewer = requireUser(context, request); | |
| 44 | โ | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 62 | + | // Members and guests alike: the chat service answers with what the viewer may read. | |
| 63 | + | const member = roleIn(viewer, params.owner) != null; | |
| 45 | 64 | const slug = params.owner.toLowerCase(); | |
| 46 | 65 | const name = "channel" in params ? (params.channel as string | undefined) : undefined; | |
| 47 | โ | // `-/chat/dm/g1t`: the viewer's direct message with @g1t, opened on first use. | |
| 66 | + | // `-/chat/dm/g1t`: the viewer's direct message with @g1t, opened on first use; members only. | |
| 67 | + | if (!member && "id" in params) throw data(null, { status: 404 }); | |
| 48 | 68 | if ("id" in params && params.id === "g1t") { | |
| 49 | 69 | const opened = await chat.g1tDm(slug, viewer).catch(() => null); | |
| 50 | 70 | if (opened?.ok) throw redirect(channelPath(slug, opened.value)); | |
| โฏ | |||
| 56 | 76 | return "unavailable" as const; | |
| 57 | 77 | }, | |
| 58 | 78 | ); | |
| 59 | โ | if (read === "missing") throw data(null, { status: 404 }); | |
| 79 | + | if (read === "missing") { | |
| 80 | + | // A channel's link that does not open: offer to ask to be let in, the same whether or not it is there. | |
| 81 | + | if (!name) throw data(null, { status: 404 }); | |
| 82 | + | const workspace = await identity.getWorkspace(slug).catch(() => null); | |
| 83 | + | if (!workspace) throw data(null, { status: 404 }); | |
| 84 | + | return data( | |
| 85 | + | { unavailable: true, title: `#${name}`, locked: { name, workspace: { slug: workspace.slug.toLowerCase(), name: workspace.name }, member } } satisfies ChannelData, | |
| 86 | + | { status: 403 }, | |
| 87 | + | ); | |
| 88 | + | } | |
| 60 | 89 | if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData; | |
| 61 | 90 | const secure = new URL(request.url).protocol === "https:"; | |
| 62 | 91 | return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } }); | |
| โฏ | |||
| 92 | 121 | older: history.ok ? history.value.older : null, | |
| 93 | 122 | joined, | |
| 94 | 123 | can_manage: detail.value.can_manage === true, | |
| 124 | + | can_make_public: detail.value.can_make_public === true, | |
| 125 | + | can_invite_guests: detail.value.can_invite_guests === true, | |
| 126 | + | can_remove_members: detail.value.can_remove_members === true, | |
| 127 | + | guest: detail.value.guest === true, | |
| 128 | + | workspace: detail.value.workspace, | |
| 129 | + | invited: detail.value.invited ?? [], | |
| 130 | + | requests: detail.value.requests ?? [], | |
| 95 | 131 | }; | |
| 96 | 132 | } | |
| 97 | 133 | ||
| โฏ | |||
| 133 | 169 | useEffect(() => { | |
| 134 | 170 | if (!loaderData.unavailable) cache.set(window.location.pathname, loaderData); | |
| 135 | 171 | }, [loaderData]); | |
| 136 | โ | if (loaderData.unavailable) return <ChatUnavailable title={loaderData.title} />; | |
| 172 | + | if (loaderData.unavailable) return "locked" in loaderData && loaderData.locked ? <RequestAccess {...loaderData.locked} /> : <ChatUnavailable title={loaderData.title} />; | |
| 137 | 173 | return <ChannelView key={loaderData.channel.id} data={loaderData} />; | |
| 138 | 174 | } | |
| 14 | 14 | */ | |
| 15 | 15 | export type ChatLayoutData = { | |
| 16 | 16 | slug: string; | |
| 17 | โ | /** The viewer's role in the workspace. */ | |
| 18 | โ | role: string; | |
| 17 | + | /** The viewer's role in the workspace; null for a guest, who is not in it. */ | |
| 18 | + | role: string | null; | |
| 19 | 19 | /** The viewer, named as chat names them (`memberName`). */ | |
| 20 | 20 | me: { id: string; username: string; display_username: string | null; display_name: string; avatar: string | null }; | |
| 21 | 21 | sidebar: ChatSidebar | null; | |
| โฏ | |||
| 25 | 25 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<ChatLayoutData> { | |
| 26 | 26 | const viewer = requireUser(context, request); | |
| 27 | 27 | const role = roleIn(viewer, params.owner); | |
| 28 | โ | if (!role) throw data(null, { status: 404 }); | |
| 29 | 28 | const slug = params.owner.toLowerCase(); | |
| 30 | โ | const [sidebar, people] = await Promise.all([sidebarOrNull(slug, viewer), workspacePeople(slug, viewer)]); | |
| 29 | + | // Someone outside the workspace comes in as a guest: the chat service | |
| 30 | + | // answers with only what they may see (its public channels, and those | |
| 31 | + | // they were asked into), and who may be mentioned is for members alone. | |
| 32 | + | const [sidebar, people] = await Promise.all([sidebarOrNull(slug, viewer), role ? workspacePeople(slug, viewer) : Promise.resolve([])]); | |
| 33 | + | if (!role && !sidebar) throw data(null, { status: 404 }); | |
| 31 | 34 | const self = people.find((person) => person.name === viewer.username.toLowerCase()); | |
| 32 | 35 | const display_username = viewer.display_username ?? self?.display_username ?? null; | |
| 33 | 36 | return { | |
| 3 | 3 | import { CHAT_VIEWER_HEADER } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/live"; | |
| 6 | โ | import { roleIn } from "../../../lib/session.server"; | |
| 7 | 6 | import { socketViewer } from "../../../lib/socket-ticket.server"; | |
| 8 | 7 | ||
| 9 | 8 | /** | |
| โฏ | |||
| 17 | 16 | // A session, or a page opened with a token by its socket ticket. | |
| 18 | 17 | const viewer = await socketViewer(context, request); | |
| 19 | 18 | if (!viewer) return new Response("Sign in to use chat.", { status: 401 }); | |
| 20 | โ | if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 }); | |
| 19 | + | // Members and guests alike: the chat service checks they may read the channel. | |
| 21 | 20 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 22 | 21 | return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } }); | |
| 23 | 22 | } | |
| 1 | โ | import { data } from "react-router"; | |
| 2 | โ | ||
| 3 | 1 | import type { Route } from "./+types/person"; | |
| 4 | 2 | import { buildCard } from "../../../lib/hovercard"; | |
| 5 | 3 | import { identity, repos } from "../../../lib/services.server"; | |
| 6 | โ | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 4 | + | import { requireUser } from "../../../lib/session.server"; | |
| 7 | 5 | ||
| 8 | 6 | /** | |
| 9 | 7 | * A person's card in Chat (components/chat/profile-card.tsx): their public | |
| โฏ | |||
| 12 | 10 | */ | |
| 13 | 11 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 14 | 12 | const viewer = requireUser(context, request); | |
| 15 | โ | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 13 | + | // Guests see people's cards too: a card is their public profile, and teams only the viewer may see. | |
| 16 | 14 | const [card, teams] = await Promise.all([ | |
| 17 | 15 | buildCard( | |
| 18 | 16 | params.username, | |
| 309 | 309 | <input type="hidden" name="intent" value="chat-channels" /> | |
| 310 | 310 | <div className="grid gap-4 sm:grid-cols-2"> | |
| 311 | 311 | <div> | |
| 312 | โ | <h4 className="mb-2 text-xs font-medium text-fg-soft">Public channels</h4> | |
| 313 | โ | <RadioGroup name="public_channels" value={chosen.public} onValueChange={(value) => setChosen((now) => ({ ...now, public: value as ChatAllowed }))} aria-label="Who can create public channels"> | |
| 312 | + | <h4 className="mb-2 text-xs font-medium text-fg-soft">Workspace channels</h4> | |
| 313 | + | <RadioGroup name="public_channels" value={chosen.public} onValueChange={(value) => setChosen((now) => ({ ...now, public: value as ChatAllowed }))} aria-label="Who can create workspace channels"> | |
| 314 | 314 | <RadioOption value="members" label="Any member" description="The default." /> | |
| 315 | โ | <RadioOption value="owners" label="Owners only" description="Members join and read public channels, but ask an owner for a new one." /> | |
| 315 | + | <RadioOption value="owners" label="Owners only" description="Members join and read workspace channels, but ask an owner for a new one. Public channels, open to anyone on g1t, are always for owners to make." /> | |
| 316 | 316 | </RadioGroup> | |
| 317 | 317 | </div> | |
| 318 | 318 | <div> |
| 84 | 84 | archived_by?: string | null; | |
| 85 | 85 | /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */ | |
| 86 | 86 | deleted?: boolean; | |
| 87 | + | /** | |
| 88 | + | * A person who is not a member of the workspace whose chat this is: a | |
| 89 | + | * guest asked into one of its channels, or someone talking in a public | |
| 90 | + | * one. Every surface marks them, so who is in the workspace is never in | |
| 91 | + | * doubt. Absent for members, @g1t and agents. | |
| 92 | + | */ | |
| 93 | + | guest?: boolean; | |
| 87 | 94 | }; | |
| 88 | 95 | ||
| 89 | 96 | /** | |
| โฏ | |||
| 107 | 114 | ||
| 108 | 115 | export type ChannelKind = "channel" | "dm"; | |
| 109 | 116 | ||
| 117 | + | /** | |
| 118 | + | * Who can find, read and join a channel: `private`, only the people in it; | |
| 119 | + | * `workspace`, anyone in the workspace; `public`, anyone signed in to g1t, | |
| 120 | + | * who talk in it as guests. A direct message is always `private`. | |
| 121 | + | */ | |
| 122 | + | export type ChannelVisibility = "private" | "workspace" | "public"; | |
| 123 | + | ||
| 110 | 124 | export type Channel = { | |
| 111 | 125 | id: string; | |
| 112 | 126 | workspace_id: string; | |
| โฏ | |||
| 114 | 128 | /** Lowercase, no `#`. Null for a direct message. */ | |
| 115 | 129 | name: string | null; | |
| 116 | 130 | topic: string | null; | |
| 131 | + | /** `visibility === "private"`, kept for older clients. */ | |
| 117 | 132 | private: boolean; | |
| 133 | + | visibility: ChannelVisibility; | |
| 118 | 134 | created_by: Principal; | |
| 119 | 135 | created_at: string; | |
| 120 | 136 | archived_at: string | null; | |
| โฏ | |||
| 321 | 337 | add_emoji: boolean; | |
| 322 | 338 | /** Whether they may change the workspace's chat settings: owners only. */ | |
| 323 | 339 | manage_settings: boolean; | |
| 340 | + | /** Whether they may make a channel public, open to anyone on g1t: owners only. */ | |
| 341 | + | make_public?: boolean; | |
| 342 | + | /** | |
| 343 | + | * Whether they may ask people outside the workspace into its channels as | |
| 344 | + | * guests: owners, and members while the workspace lets members invite | |
| 345 | + | * outside collaborators. | |
| 346 | + | */ | |
| 347 | + | invite_guests?: boolean; | |
| 324 | 348 | }; | |
| 325 | 349 | ||
| 326 | 350 | /** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */ | |
| โฏ | |||
| 330 | 354 | channels: Channel[]; | |
| 331 | 355 | }; | |
| 332 | 356 | ||
| 333 | โ | /** A change to a channel: its name, its topic, or whether it is archived. Anything left out stays. */ | |
| 334 | โ | export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean }; | |
| 357 | + | /** | |
| 358 | + | * A change to a channel: its name, its topic, whether it is archived, or | |
| 359 | + | * whether a channel that is not private is open to anyone on g1t | |
| 360 | + | * (`workspace` or `public`; workspace owners only). Anything left out stays. | |
| 361 | + | */ | |
| 362 | + | export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean; visibility?: "workspace" | "public" }; | |
| 335 | 363 | ||
| 336 | 364 | /** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */ | |
| 337 | 365 | export type EmojiFile = { data: string }; | |
| โฏ | |||
| 355 | 383 | ||
| 356 | 384 | export type ChatSidebar = { | |
| 357 | 385 | entries: ChatSidebarEntry[]; | |
| 358 | โ | /** Public channels in the workspace the viewer has not joined. */ | |
| 386 | + | /** Channels the viewer could join and has not: for a guest, the public ones only. */ | |
| 359 | 387 | browsable: number; | |
| 360 | 388 | /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */ | |
| 361 | 389 | can: ChatPermissions; | |
| 390 | + | /** | |
| 391 | + | * The viewer is not a member of this workspace: a guest, who sees only | |
| 392 | + | * the channels they are in and its public ones. Absent for members. | |
| 393 | + | */ | |
| 394 | + | guest?: boolean; | |
| 395 | + | /** The workspace whose chat this is. */ | |
| 396 | + | workspace?: ChatWorkspace; | |
| 397 | + | /** Channels of other workspaces the viewer is a guest in. */ | |
| 398 | + | elsewhere?: GuestChannel[]; | |
| 399 | + | /** Invitations into other workspaces' channels, waiting for the viewer's answer. */ | |
| 400 | + | invitations?: ChannelInvitation[]; | |
| 401 | + | }; | |
| 402 | + | ||
| 403 | + | /** A workspace as chat names it to someone who may not be in it. */ | |
| 404 | + | export type ChatWorkspace = { slug: string; name: string; avatar: string | null }; | |
| 405 | + | ||
| 406 | + | /** A channel of another workspace that the viewer is a guest in, for their own sidebar. */ | |
| 407 | + | export type GuestChannel = { | |
| 408 | + | workspace: ChatWorkspace; | |
| 409 | + | channel: Channel; | |
| 410 | + | unread: number; | |
| 411 | + | mentions: number; | |
| 412 | + | }; | |
| 413 | + | ||
| 414 | + | /** An invitation for someone outside a workspace to join one of its channels as a guest. */ | |
| 415 | + | export type ChannelInvitation = { | |
| 416 | + | id: string; | |
| 417 | + | workspace: ChatWorkspace; | |
| 418 | + | channel: Channel; | |
| 419 | + | /** Who it is for. */ | |
| 420 | + | invitee: MemberProfile; | |
| 421 | + | invited_by: MemberProfile; | |
| 422 | + | created_at: string; | |
| 362 | 423 | }; | |
| 363 | 424 | ||
| 364 | 425 | /** | |
| โฏ | |||
| 396 | 457 | newer?: string | null; | |
| 397 | 458 | }; | |
| 398 | 459 | ||
| 399 | โ | export type NewChannel = { name: string; topic?: string | null; private?: boolean }; | |
| 460 | + | /** | |
| 461 | + | * A new channel. `visibility` wins over `private`; left out, a channel is | |
| 462 | + | * `workspace`, or `private` when `private` is set. | |
| 463 | + | */ | |
| 464 | + | export type NewChannel = { name: string; topic?: string | null; private?: boolean; visibility?: ChannelVisibility }; | |
| 400 | 465 | ||
| 401 | 466 | export type PostMessage = { | |
| 402 | 467 | body: string; | |
| โฏ | |||
| 438 | 503 | */ | |
| 439 | 504 | export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer"; | |
| 440 | 505 | ||
| 441 | โ | /** A channel with its members, and whether the viewer may rename or archive it. */ | |
| 442 | โ | export type ChannelDetail = { channel: Channel; members: ChannelMember[]; can_manage: boolean }; | |
| 506 | + | /** | |
| 507 | + | * A channel with its members, and what the viewer may do there. `invited` | |
| 508 | + | * lists guests' open invitations, for those who may ask guests in or take | |
| 509 | + | * people out; empty for anyone else. | |
| 510 | + | */ | |
| 511 | + | export type ChannelDetail = { | |
| 512 | + | channel: Channel; | |
| 513 | + | members: ChannelMember[]; | |
| 514 | + | /** Whether the viewer may rename or archive it. */ | |
| 515 | + | can_manage: boolean; | |
| 516 | + | /** Whether the viewer may make it public, or keep it to the workspace. */ | |
| 517 | + | can_make_public?: boolean; | |
| 518 | + | /** Whether the viewer may ask people outside the workspace in. */ | |
| 519 | + | can_invite_guests?: boolean; | |
| 520 | + | /** Whether the viewer may take people out of it. */ | |
| 521 | + | can_remove_members?: boolean; | |
| 522 | + | /** The viewer is not a member of the workspace: a guest here. */ | |
| 523 | + | guest?: boolean; | |
| 524 | + | /** The workspace whose channel this is. */ | |
| 525 | + | workspace?: ChatWorkspace; | |
| 526 | + | invited?: ChannelInvitation[]; | |
| 527 | + | /** Requests to join waiting for an answer, for those who could let the person in. */ | |
| 528 | + | requests?: ChannelAccessRequest[]; | |
| 529 | + | }; | |
| 530 | + | ||
| 531 | + | /** | |
| 532 | + | * Someone asking to be let into a channel they cannot open. `guest` when | |
| 533 | + | * they are not in the workspace: letting them in makes them a guest. | |
| 534 | + | */ | |
| 535 | + | export type ChannelAccessRequest = { | |
| 536 | + | id: string; | |
| 537 | + | channel_id: string; | |
| 538 | + | requester: MemberProfile; | |
| 539 | + | note: string | null; | |
| 540 | + | created_at: string; | |
| 541 | + | }; | |
| 443 | 542 | ||
| 444 | 543 | export type ChatApi = { | |
| 445 | 544 | sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>; | |
| โฏ | |||
| 481 | 580 | openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>; | |
| 482 | 581 | join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 483 | 582 | leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 484 | โ | /** Adds a person. An invite grants read, never write. */ | |
| 485 | โ | invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>; | |
| 486 | 583 | /** | |
| 584 | + | * Adds a person. A member of the workspace joins at once; anyone else is | |
| 585 | + | * sent an invitation and joins as a guest when they accept (`invited`). | |
| 586 | + | */ | |
| 587 | + | invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<{ invited: boolean }>>; | |
| 588 | + | /** | |
| 589 | + | * Takes someone out of a channel, or takes back their open invitation: | |
| 590 | + | * the workspace's owners, and the channel's own owners where the | |
| 591 | + | * workspace lets them manage channels. Someone outside the workspace | |
| 592 | + | * taken out of a public channel cannot join it again on their own. | |
| 593 | + | */ | |
| 594 | + | removeMember(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>; | |
| 595 | + | /** The viewer's invitations into channels of workspaces they are not in. */ | |
| 596 | + | channelInvitations(viewer: User): Promise<Result<ChannelInvitation[]>>; | |
| 597 | + | /** Accepts or declines one; accepting answers the channel's page. */ | |
| 598 | + | answerChannelInvitation(viewer: User, id: string, accept: boolean): Promise<Result<{ to: string | null }>>; | |
| 599 | + | /** | |
| 600 | + | * Asks to be let into a channel, by the name in its link. Answers the | |
| 601 | + | * same whether or not there is such a channel the viewer may ask about, | |
| 602 | + | * so asking never tells them one exists; a request is kept only when it | |
| 603 | + | * does. Already able to read it: nothing to ask. | |
| 604 | + | */ | |
| 605 | + | requestAccess(workspace: string, name: string, viewer: User, note: string | null): Promise<Result<{ requested: true }>>; | |
| 606 | + | /** Lets the person in, or turns them down: anyone who could have added them. */ | |
| 607 | + | answerAccessRequest(workspace: string, channelId: string, viewer: User, id: string, approve: boolean): Promise<Result<null>>; | |
| 608 | + | /** | |
| 487 | 609 | * Newest first. Without `thread_root`, the channel's top-level messages; | |
| 488 | 610 | * with it, that thread's replies, plus the message they reply to as the | |
| 489 | 611 | * oldest once the page reaches the start of the thread (`older` null). | |
| โฏ | |||
| 605 | 727 | leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }), | |
| 606 | 728 | invite: (workspace, channelId, viewer, member) => | |
| 607 | 729 | call("invite", { workspace, channel_id: channelId, viewer, member }), | |
| 730 | + | removeMember: (workspace, channelId, viewer, member) => | |
| 731 | + | call("remove_member", { workspace, channel_id: channelId, viewer, member }), | |
| 732 | + | channelInvitations: (viewer) => call("channel_invitations", { viewer }), | |
| 733 | + | answerChannelInvitation: (viewer, id, accept) => call("answer_channel_invitation", { viewer, id, accept }), | |
| 734 | + | requestAccess: (workspace, name, viewer, note) => call("request_access", { workspace, name, viewer, note }), | |
| 735 | + | answerAccessRequest: (workspace, channelId, viewer, id, approve) => | |
| 736 | + | call("answer_access_request", { workspace, channel_id: channelId, viewer, id, approve }), | |
| 608 | 737 | messages: (workspace, channelId, viewer, page) => | |
| 609 | 738 | call("messages", { | |
| 610 | 739 | workspace, | |
| 183 | 183 | userForSshKey: (fingerprint) => call("user_for_ssh_key", { fingerprint }), | |
| 184 | 184 | userByUsername: (username) => call("user_by_username", { username }), | |
| 185 | 185 | usernames: (ids) => call("usernames", { ids }), | |
| 186 | + | accounts: (ids) => call("accounts", { ids }), | |
| 186 | 187 | usersForAudience: (ids) => call("users_for_audience", { ids }), | |
| 187 | 188 | profile: (username) => call("profile", { username }), | |
| 188 | 189 | updateProfile: (actor, fields) => call("update_profile", { actor, ...fields }), |
| 8 | 8 | import type { TeamCreation, TeamsClient } from "./teams"; | |
| 9 | 9 | import type { PeopleClient } from "./people"; | |
| 10 | 10 | import type { DeployKeysClient } from "./deploy-keys"; | |
| 11 | โ | import type { EmailConfirmed } from "./accounts"; | |
| 11 | + | import type { EmailConfirmed, EmailOwner } from "./accounts"; | |
| 12 | 12 | ||
| 13 | 13 | export type User = { | |
| 14 | 14 | id: string; | |
| โฏ | |||
| 925 | 925 | userByUsername(username: string): Promise<Viewer>; | |
| 926 | 926 | /** The names behind account and workspace ids; unknown ids are left out. */ | |
| 927 | 927 | usernames(ids: string[]): Promise<Record<string, string>>; | |
| 928 | + | /** The accounts behind these ids (at most 200), by id, with their usernames and avatars; unknown ids are left out. */ | |
| 929 | + | accounts(ids: string[]): Promise<Record<string, EmailOwner>>; | |
| 928 | 930 | /** | |
| 929 | 931 | * Internal: the people behind these ids (at most 50) with their | |
| 930 | 932 | * workspaces, roles and repository grants, as a signed-in viewer has | |
| 1 | 1 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 3 | โ | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska" | "abr" | "mcp" | "mkt" | "plg" | "con" | "cgr"; | |
| 3 | + | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska" | "abr" | "mcp" | "mkt" | "plg" | "con" | "cgr" | "civ" | "crq"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 1 | + | -- Public channels and guests (docs.g1t.sh/guides/chat/, "Public channels" | |
| 2 | + | -- and "Guests"). | |
| 3 | + | -- | |
| 4 | + | -- A public channel is open to anyone signed in to g1t, not only the | |
| 5 | + | -- workspace's members: they read it, join it and talk in it. Only a | |
| 6 | + | -- channel that is not private can be public. | |
| 7 | + | ALTER TABLE channels ADD COLUMN public INTEGER NOT NULL DEFAULT 0; | |
| 8 | + | ||
| 9 | + | -- Someone outside the workspace asked into one of its channels: a guest. | |
| 10 | + | -- They join it when they accept; until then the channel is not theirs to | |
| 11 | + | -- read. One open invitation per person and channel. | |
| 12 | + | CREATE TABLE channel_invitations ( | |
| 13 | + | id TEXT PRIMARY KEY, | |
| 14 | + | channel_id TEXT NOT NULL, | |
| 15 | + | workspace_id TEXT NOT NULL, | |
| 16 | + | -- 'user:<id>' | |
| 17 | + | invitee TEXT NOT NULL, | |
| 18 | + | invited_by TEXT NOT NULL, | |
| 19 | + | created_at TEXT NOT NULL, | |
| 20 | + | answered_at TEXT, | |
| 21 | + | -- 1 accepted, 0 declined or revoked; null while open. | |
| 22 | + | accepted INTEGER | |
| 23 | + | ); | |
| 24 | + | CREATE UNIQUE INDEX channel_invitations_open ON channel_invitations (channel_id, invitee) WHERE answered_at IS NULL; | |
| 25 | + | CREATE INDEX channel_invitations_by_invitee ON channel_invitations (invitee, answered_at); | |
| 26 | + | CREATE INDEX channel_invitations_by_channel ON channel_invitations (channel_id, accepted); | |
| 27 | + | ||
| 28 | + | -- Someone asking to be let into a channel they cannot open: a private one, | |
| 29 | + | -- or one kept to the workspace when they are not in it. Whoever could add | |
| 30 | + | -- them answers. Asked by the channel's name from its link, and stored only | |
| 31 | + | -- when there is such a channel, so asking says nothing of one that is not | |
| 32 | + | -- theirs to see. One open request per person and channel. | |
| 33 | + | CREATE TABLE channel_requests ( | |
| 34 | + | id TEXT PRIMARY KEY, | |
| 35 | + | channel_id TEXT NOT NULL, | |
| 36 | + | workspace_id TEXT NOT NULL, | |
| 37 | + | -- 'user:<id>' | |
| 38 | + | requester TEXT NOT NULL, | |
| 39 | + | note TEXT, | |
| 40 | + | created_at TEXT NOT NULL, | |
| 41 | + | answered_at TEXT, | |
| 42 | + | answered_by TEXT, | |
| 43 | + | -- 1 let in, 0 turned down or withdrawn; null while open. | |
| 44 | + | approved INTEGER | |
| 45 | + | ); | |
| 46 | + | CREATE UNIQUE INDEX channel_requests_open ON channel_requests (channel_id, requester) WHERE answered_at IS NULL; | |
| 47 | + | CREATE INDEX channel_requests_by_channel ON channel_requests (channel_id, answered_at); | |
| 48 | + | ||
| 49 | + | -- People removed from a channel by its owners or the workspace's: someone | |
| 50 | + | -- outside the workspace removed from a public channel cannot join it again | |
| 51 | + | -- on their own. Asking them in again lifts it. | |
| 52 | + | CREATE TABLE channel_removals ( | |
| 53 | + | channel_id TEXT NOT NULL, | |
| 54 | + | principal TEXT NOT NULL, | |
| 55 | + | removed_by TEXT NOT NULL, | |
| 56 | + | removed_at TEXT NOT NULL, | |
| 57 | + | PRIMARY KEY (channel_id, principal) | |
| 58 | + | ); |
| 1 | + | import { test } from "node:test"; | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | ||
| 4 | + | import type { User } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { MAX_REQUEST_NOTE, mayAdmit, mayInviteGuests, mayJoin, mayRead, mayVisit, requestNote, visibilityOf } from "./access.ts"; | |
| 7 | + | ||
| 8 | + | const person = (over: Partial<User> = {}): User => ({ id: "usr_1", username: "ana", verified: true, ...over }); | |
| 9 | + | ||
| 10 | + | test("a channel is private, kept to the workspace, or public; a direct message is always private", () => { | |
| 11 | + | assert.equal(visibilityOf({ kind: "channel", private: 1, public: 0 }), "private"); | |
| 12 | + | assert.equal(visibilityOf({ kind: "channel", private: 0, public: 0 }), "workspace"); | |
| 13 | + | assert.equal(visibilityOf({ kind: "channel", private: 0, public: 1 }), "public"); | |
| 14 | + | // Private wins: a private channel is never open to anyone. | |
| 15 | + | assert.equal(visibilityOf({ kind: "channel", private: 1, public: 1 }), "private"); | |
| 16 | + | assert.equal(visibilityOf({ kind: "dm", private: 1, public: 1 }), "private"); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("who may read: anyone in it; members for a workspace channel; anyone for a public one", () => { | |
| 20 | + | for (const role of ["owner", "member", null] as const) { | |
| 21 | + | assert.equal(mayRead("private", role, true), true); | |
| 22 | + | assert.equal(mayRead("private", role, false), false); | |
| 23 | + | assert.equal(mayRead("public", role, false), true); | |
| 24 | + | } | |
| 25 | + | assert.equal(mayRead("workspace", "member", false), true); | |
| 26 | + | assert.equal(mayRead("workspace", null, false), false); | |
| 27 | + | // A guest asked into a workspace channel reads it. | |
| 28 | + | assert.equal(mayRead("workspace", null, true), true); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("someone outside the workspace taken out of a public channel can't join it again on their own", () => { | |
| 32 | + | assert.equal(mayJoin("public", null, false, false), true); | |
| 33 | + | assert.equal(mayJoin("public", null, true, false), false); | |
| 34 | + | // Members are never kept out this way; leaving or being taken out is not a ban for them. | |
| 35 | + | assert.equal(mayJoin("public", "member", true, false), true); | |
| 36 | + | assert.equal(mayJoin("workspace", null, false, false), false); | |
| 37 | + | assert.equal(mayJoin("private", "owner", false, false), false); | |
| 38 | + | assert.equal(mayJoin("workspace", "member", false, true), false); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | test("owners always invite guests; members while the workspace lets them", () => { | |
| 42 | + | const owner = person({ workspaces: [{ slug: "flagon-io", role: "owner" }] }); | |
| 43 | + | assert.equal(mayInviteGuests(owner, "Flagon-IO"), true); | |
| 44 | + | const member = (allowed?: boolean) => | |
| 45 | + | person({ | |
| 46 | + | workspaces: [ | |
| 47 | + | { | |
| 48 | + | slug: "flagon-io", | |
| 49 | + | role: "member", | |
| 50 | + | ...(allowed === undefined ? {} : { privileges: { members_can_invite_outside_collaborators: allowed } as never }), | |
| 51 | + | }, | |
| 52 | + | ], | |
| 53 | + | }); | |
| 54 | + | assert.equal(mayInviteGuests(member(), "flagon-io"), true); | |
| 55 | + | assert.equal(mayInviteGuests(member(true), "flagon-io"), true); | |
| 56 | + | assert.equal(mayInviteGuests(member(false), "flagon-io"), false); | |
| 57 | + | assert.equal(mayInviteGuests(person(), "flagon-io"), false); | |
| 58 | + | assert.equal(mayInviteGuests(null, "flagon-io"), false); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("only signed-in people with a confirmed address come in as guests", () => { | |
| 62 | + | assert.equal(mayVisit(person()), null); | |
| 63 | + | assert.match(mayVisit(null) ?? "", /Sign in/); | |
| 64 | + | assert.match(mayVisit(person({ verified: false })) ?? "", /Confirm your email/); | |
| 65 | + | assert.match(mayVisit(person({ kind: "workspace" })) ?? "", /Only people/); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | test("letting someone in: anyone in the channel for a member; those who invite guests for anyone else", () => { | |
| 69 | + | assert.equal(mayAdmit(true, true, false), true); | |
| 70 | + | assert.equal(mayAdmit(false, true, false), false); | |
| 71 | + | assert.equal(mayAdmit(false, true, true), true); | |
| 72 | + | assert.equal(mayAdmit(true, false, true), false); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("a request's note is trimmed, cut to length, and null when empty", () => { | |
| 76 | + | assert.equal(requestNote(" hi "), "hi"); | |
| 77 | + | assert.equal(requestNote(" "), null); | |
| 78 | + | assert.equal(requestNote(42), null); | |
| 79 | + | assert.equal(requestNote("x".repeat(MAX_REQUEST_NOTE + 10))?.length, MAX_REQUEST_NOTE); | |
| 80 | + | }); |
| 1 | + | /** | |
| 2 | + | * Who may open a conversation, and who is a guest (docs.g1t.sh/guides/chat/, | |
| 3 | + | * "Who can see a channel"). Pure, so it is tested apart from the service. | |
| 4 | + | * | |
| 5 | + | * A channel is private (only the people in it), kept to its workspace | |
| 6 | + | * (anyone in it), or public (anyone signed in to g1t). Someone in a | |
| 7 | + | * workspace's chat who is not a member of the workspace is a guest: asked | |
| 8 | + | * into a channel, or talking in a public one. Guests never see the rest of | |
| 9 | + | * the workspace, are never in a direct message, and never talk to @g1t, | |
| 10 | + | * which works for the workspace's members alone. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | import type { ChannelVisibility, User } from "@g1t/contracts"; | |
| 14 | + | ||
| 15 | + | /** A channel row's say in who may open it. */ | |
| 16 | + | export type VisibilityRow = { kind: "channel" | "dm"; private: number; public: number }; | |
| 17 | + | ||
| 18 | + | export function visibilityOf(row: VisibilityRow): ChannelVisibility { | |
| 19 | + | if (row.kind === "dm" || row.private) return "private"; | |
| 20 | + | return row.public ? "public" : "workspace"; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /** The viewer's role in a workspace; null for someone outside it. */ | |
| 24 | + | export type Standing = "owner" | "member" | null; | |
| 25 | + | ||
| 26 | + | /** | |
| 27 | + | * Whether someone may read a conversation: anyone in it; else anyone in | |
| 28 | + | * the workspace when it is kept to the workspace or public; else anyone | |
| 29 | + | * signed in when it is public. Nothing private without being in it. | |
| 30 | + | */ | |
| 31 | + | export function mayRead(visibility: ChannelVisibility, role: Standing, inIt: boolean): boolean { | |
| 32 | + | if (inIt) return true; | |
| 33 | + | if (visibility === "private") return false; | |
| 34 | + | return visibility === "public" || role !== null; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * Whether someone not yet in a channel may join it on their own: whoever | |
| 39 | + | * may read it, except someone outside the workspace whom its owners took | |
| 40 | + | * out of it, and anything archived. | |
| 41 | + | */ | |
| 42 | + | export function mayJoin(visibility: ChannelVisibility, role: Standing, removed: boolean, archived: boolean): boolean { | |
| 43 | + | if (archived || !mayRead(visibility, role, false)) return false; | |
| 44 | + | return role !== null || !removed; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** | |
| 48 | + | * Whether someone may ask people outside the workspace into its channels: | |
| 49 | + | * owners; members while the workspace lets members invite outside | |
| 50 | + | * collaborators (Settings, Permissions), as for repositories. | |
| 51 | + | */ | |
| 52 | + | export function mayInviteGuests(viewer: User | null, slug: string): boolean { | |
| 53 | + | const membership = viewer?.workspaces?.find((m) => m.slug === slug.toLowerCase()); | |
| 54 | + | if (!membership) return false; | |
| 55 | + | if (membership.role === "owner") return true; | |
| 56 | + | return membership.privileges?.members_can_invite_outside_collaborators !== false; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** | |
| 60 | + | * Whether a signed-in viewer may come into a workspace's chat as a guest: | |
| 61 | + | * a person (not a token for a workspace) whose email address is confirmed, | |
| 62 | + | * so a public channel is not open to throwaway accounts. | |
| 63 | + | */ | |
| 64 | + | export function mayVisit(viewer: User | null): string | null { | |
| 65 | + | if (!viewer) return "Sign in to use chat."; | |
| 66 | + | if (viewer.kind && viewer.kind !== "user") return "Only people can talk in another workspace's channels."; | |
| 67 | + | if (!viewer.verified) return "Confirm your email address to talk in other workspaces' channels."; | |
| 68 | + | return null; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** | |
| 72 | + | * Whether someone may be let in on a request, or asked in: anyone in the | |
| 73 | + | * channel may add a member of the workspace (as `invite` does); only those | |
| 74 | + | * who may invite guests may let in someone outside it. | |
| 75 | + | */ | |
| 76 | + | export function mayAdmit(requesterIsMember: boolean, approverInChannel: boolean, approverMayInviteGuests: boolean): boolean { | |
| 77 | + | if (!approverInChannel) return false; | |
| 78 | + | return requesterIsMember || approverMayInviteGuests; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** The longest note on a request to join. */ | |
| 82 | + | export const MAX_REQUEST_NOTE = 300; | |
| 83 | + | ||
| 84 | + | /** A request's note, trimmed and cut to length; null when empty. */ | |
| 85 | + | export function requestNote(note: unknown): string | null { | |
| 86 | + | const text = typeof note === "string" ? note.trim() : ""; | |
| 87 | + | return text ? text.slice(0, MAX_REQUEST_NOTE) : null; | |
| 88 | + | } |
Loading the diffโฆ
Loading the diffโฆ