Chat channels are private, kept to the workspace, or public, and people outside a workspace can talk in its channels as guests (#44). A public channel is open to anyone signed in to g1t with a confirmed address: they read it by its link, join it and talk in it. Only workspace owners make one, when creating it or from its details. Someone outside the workspace can also be invited into any channel by username and joins as a guest once they accept; owners can always invite guests, and members can while the workspace lets members invite outside collaborators, the same setting as for repositories. Opening the link of a channel you cannot open offers Ask to join with a note, and the page is the same whether or not the channel exists. The people who could add you see the request in the channel's details and let you in or decline.
Guests are marked Guest wherever they show: beside their name on messages, among the channel's people, and in requests and invitations. A guest's own sidebar lists their channels in other workspaces and their invitations. Opening one shows that workspace's chat as a guest sees it: only their channels and its public ones, no direct messages, a Guest banner and a way back. @g1t works for the workspace's members only: a guest is never in a direct message and never starts an answer. Channel and workspace owners can take people out of a channel. Someone outside the workspace taken out of a public channel cannot rejoin on their own, and making a channel workspace-only again removes the guests who came in on their own while keeping those who were asked in. Today's "public" channels, open to anyone in the workspace, are now called Workspace channels in the UI and docs. Chat migration 0007 adds channels.public and the channel_invitations, channel_requests and channel_removals tables, and personal-data erasure covers them. The rules live in services/chat/src/access.ts with their tests. The site's chat routes no longer answer 404 to people outside the workspace: the chat service decides what they may see.
- Checked:
- chat tests (83 passing, 7 new), npm test across workspaces (no failures), scripts/deploy tests (92), tsc -b in apps/web, the mobile app and the chat service, react-router build, and the migration plus the new queries on a local D1.
- G1t-Session:
- ses_01m4yakrdrev793j46v0ptvwd5
| 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 | + | } |
Comments
Sign in to comment.