Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

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
syntaqxcommitted Parent9c1792bBrowse files
27 files+1666−2090/27 viewed
+92−21
1717 answer arrives in the place you asked.
1818
1919 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.
2223
2324 Open **Chat** in the rail on the left, or go to
2425 `g1t.sh/<workspace>/-/chat`.
6566 A channel is a named conversation, such as `#web`, `#releases` or
6667 `#support`. Every workspace starts with `#general`, and everyone new is put
6768 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)).
7170
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 |
7780
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+
7885 ### Create a channel
7986
8087 <Steps>
8491 lowercase letters, digits, dashes and underscores. `Web Team` becomes
8592 `web-team`.
8693 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**.
8996 5. Choose **Create channel**. You are its owner and first member. Invite
9097 people once it is made.
9198
9299 </Steps>
93100
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
96103 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.
98113
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+
99170 ### Join, leave and browse
100171
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,
104175 and choose **Join** to post in it and see it in your sidebar. A private channel
105176 you're not in never appears.
106177 - **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.
108179 - When you join, everything already said counts as read, so a busy channel
109180 doesn't greet you with its whole history as unread.
110181
394465
395466 | Setting | Choices |
396467 | --- | --- |
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. |
398469 | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. |
399470 | **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. |
401472
402473 Changing a setting doesn't change what already exists: channels already
403474 made stay, whoever made them.
+1−1
3636
3737 | Setting | What it decides | Today |
3838 | --- | --- | --- |
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 |
4040 | **Private channels** | Who can create them: **Any member** or **Owners only**. | Any member |
4141
4242 Renaming and archiving channels, custom emoji and default channels are on
+50−35
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";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { useFetcher, useNavigate, useRouteLoaderData } from "react-router";
44
5−import type { ChatSidebar, Result } from "@g1t/contracts";
5+import type { ChannelVisibility, ChatSidebar, Result } from "@g1t/contracts";
66
77 import { Avatar } from "../ui/avatar";
88 import { Button } from "../ui/button";
9−import { Card } from "../ui/card";
109 import { Hint } from "../ui/hint";
1110 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
12−import { Switch } from "../ui/switch";
11+import { RadioGroup, RadioOption } from "../ui/radio-group";
1312 import { channelName, shownHandle, shownName } from "../../lib/chat";
1413 import type { ChatLayoutData } from "../../routes/workspace/chat/layout";
1514 import { useLiveCounts } from "../../lib/notify-client";
9392 * What the viewer may make, from the workspace's chat settings (Settings,
9493 * Chat). Until the sidebar arrives, everything: the chat service checks.
9594 */
96−export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } {
95+export function useChannelCreation(): { public: boolean; private: boolean; any: boolean; open: boolean } {
9796 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;
9999 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 };
101103 }
102104
103105 /**
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.
107110 */
108111 export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
109112 const [open, setOpen] = useState(false);
110113 const [name, setName] = useState("");
111114 const [topic, setTopic] = useState("");
112− const [secret, setSecret] = useState(false);
115+ const [chosen, setChosen] = useState<ChannelVisibility>("workspace");
113116 const [error, setError] = useState<string | null>(null);
114117 const [busy, setBusy] = useState(false);
115118 const send = useChatSend(slug);
116119 const navigate = useNavigate();
117120 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";
120125 const clean = channelName(name);
121126 const submit = async () => {
122127 if (!clean) return setError("Give the channel a name.");
123128 setBusy(true);
124129 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 });
126131 setBusy(false);
127132 if (!made.ok) return setError(made.error.message);
128133 setOpen(false);
177182 <label className="grid gap-1.5">
178183 <span className="text-sm font-medium text-muted">Name</span>
179184 <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" />}
181186 <input
182187 autoFocus
183188 value={name}
205210 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
206211 />
207212 </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>
228243 {error && <p className="text-sm text-danger">{error}</p>}
229244 <DialogFooter>
230245 <Button type="button" onClick={() => setOpen(false)} variant="outline">
+262−24
2424
2525 import {
2626 type Channel,
27+ type ChannelAccessRequest,
2728 type ChannelChange,
29+ type ChannelInvitation,
30+ type ChannelVisibility,
2831 type ChannelMember,
2932 type ChatLiveEvent,
3033 type ChatMessage,
3942 import { CardBox, CardToasts, showToast } from "./card";
4043 import { Composer } from "./composer";
4144 import { type LiveState, useChatLive } from "./live";
42−import { AgentPill, MemberAvatar } from "./marks";
45+import { AgentPill, GuestPill, MemberAvatar } from "./marks";
46+import { ChannelMark } from "./sidebar";
4347 import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
4448 import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
4549
7175 import { Button } from "../ui/button";
7276 import { Card } from "../ui/card";
7377 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
78+import { Switch } from "../ui/switch";
7479 import {
7580 type Mentionable,
7681 type ShownMessage,
783788 <span>{formerAgentLine(formerAgent)}</span>
784789 </Card>
785790 ) : joined ? (
791+ <>
792+ {data.guest && (
793+ <p className="mb-1.5 px-1 text-xs text-faint">
794+ You&apos;re a guest in {data.workspace?.name ?? "this workspace"}: everyone here sees you marked Guest, and @g1t answers its members only.
795+ </p>
796+ )}
786797 <Composer
787798 draftKey={data.channel.id}
788799 placeholder={withG1t ? "Ask g1t about your work" : placeholder}
792803 onTyping={onTyping}
793804 autoFocus
794805 />
806+ </>
795807 ) : (
796808 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
797809 <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+ )}
799820 </p>
800821 <Button
801822 type="button"
844865 slug={slug}
845866 people={people}
846867 onAdded={(m) => setMembers((now) => [...now, m])}
868+ onRemoved={(id) => setMembers((now) => now.filter((m) => !(m.member.kind === "user" && m.member.id === id)))}
847869 joined={joined}
848870 canManage={canManage}
849871 onUpdate={updateChannel}
922944 {channel.kind === "channel" ? (
923945 <>
924946 <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>
934952 <span className="truncate">{channel.name}</span>
935953 </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+ )}
936966 <StarButton starred={starred} onClick={onStar} />
937967 {muted && (
938968 <Hint label="Muted: no unread count or notifications">
9851015 <Hint label={info ? "Hide details" : "Members and details"}>
9861016 <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">
9871017 <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+ )}
9881023 </Button>
9891024 </Hint>
9901025 )}
13451380 const author = message.author;
13461381 const time = clock(message.created_at, zone);
13471382 const press = useLongPress(onLongPress);
1383+ // Whose chat this is, for a guest's mark.
1384+ const workspaceName = useChatData()?.sidebar?.workspace?.name ?? null;
13481385 return (
13491386 <article
13501387 {...press}
13761413 </MemberCard>
13771414 {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />}
13781415 {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />}
1416+ {author.guest && <GuestPill className="self-center" workspace={workspaceName} />}
13791417 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
13801418 {time}
13811419 </time>
16291667 );
16301668 }
16311669
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+
16321677 /**
16331678 * A channel's name, topic and visibility, and changing them: any member
16341679 * 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.
16361683 */
16371684 function ChannelAbout({
16381685 channel,
16391686 joined,
16401687 canManage,
1688+ canMakePublic,
1689+ guest,
16411690 onUpdate,
16421691 }: {
16431692 channel: Channel;
16441693 joined: boolean;
16451694 canManage: boolean;
1695+ canMakePublic: boolean;
1696+ /** The viewer is a guest: they change nothing. */
1697+ guest: boolean;
16461698 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
16471699 }) {
16481700 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
16531705 const general = channel.name === "general";
16541706 const archived = !!channel.archived_at;
16551707 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");
16571710 const save = async (change: ChannelChange) => {
16581711 setBusy(true);
16591712 setError(null);
16771730 }}
16781731 >
16791732 <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+ )}
16811738 <input
16821739 autoFocus
16831740 value={value}
17281785 </div>
17291786 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
17301787 <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]}
17341793 </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+ )}
17351812 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
17361813 </section>
17371814 </Card>
17791856 slug,
17801857 people,
17811858 onAdded,
1859+ onRemoved,
17821860 joined,
17831861 canManage,
17841862 onUpdate,
17881866 slug: string;
17891867 people: Mentionable[];
17901868 onAdded: (member: ChannelMember) => void;
1869+ /** Someone taken out of the channel, by their account id. */
1870+ onRemoved: (id: string) => void;
17911871 joined: boolean;
17921872 /** Whether the viewer may rename, archive and unarchive it. */
17931873 canManage: boolean;
17941874 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
17951875 }) {
17961876 const channel = data.channel;
1877+ const send = useChatSend(slug);
1878+ const me = useChatData()?.me.id ?? null;
1879+ const workspaceName = data.workspace?.name ?? null;
17971880 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+ };
17981901 return (
17991902 <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+ )}
18011939 <section className="mt-5">
18021940 <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+ )}
18051969 </div>
18061970 <ul className="space-y-px">
18071971 {humans.map(({ member, role }) => (
1808− <li key={member.id}>
1972+ <li key={member.id} className="group/person relative">
18091973 <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">
18101974 <MemberAvatar member={member} size={26} presence />
18111975 <span className="min-w-0 grow truncate text-sm">
18131977 {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>}
18141978 <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert />
18151979 </span>
1980+ {member.guest && <GuestPill workspace={workspaceName} />}
18161981 {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
18171982 </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+ )}
18181997 </li>
18191998 ))}
18201999 </ul>
18212000 </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&apos;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>}
18222023 </div>
18232024 );
18242025 }
18252026
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+ */
18272032 function AddMember({
18282033 slug,
18292034 channelId,
18302035 people,
18312036 members,
2037+ guests,
2038+ workspaceName,
18322039 onAdded,
2040+ onInvited,
18332041 }: {
18342042 slug: string;
18352043 channelId: string;
18362044 people: Mentionable[];
18372045 members: ChannelMember[];
2046+ guests: boolean;
2047+ workspaceName: string | null;
18382048 onAdded: (member: ChannelMember) => void;
2049+ onInvited: (username: string) => void;
18392050 }) {
18402051 const [open, setOpen] = useState(false);
18412052 const [query, setQuery] = useState("");
18472058 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
18482059 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
18492060 .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+ };
18502074 const add = async (person: Mentionable) => {
18512075 setError(null);
18522076 const done = await send({ intent: "invite", channel_id: channelId, member: `user:${person.name}` });
18752099 autoFocus
18762100 value={query}
18772101 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"}
18792103 aria-label="Add a person"
18802104 autoComplete="off"
18812105 data-1p-ignore
18822106 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"
18832107 />
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>}
18852109 {options.map((person) => (
18862110 <button
18872111 key={`${person.kind}:${person.name}`}
18932117 <span className="min-w-0 grow truncate">{person.display_name}</span>
18942118 </button>
18952119 ))}
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&apos;ll see it in their Chat sidebar.</p>}
18962134 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
18972135 </PopoverContent>
18982136 </Popover>
+62−2
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";
33 import { useParams } from "react-router";
44
55 import { CreateChannelButton, NewMessageButton } from "./actions";
6+import { Button } from "../ui/button";
67 import { Card } from "../ui/card";
8+import { Textarea } from "../ui/textarea";
79
810 /** A calm full-height panel for a conversation that cannot be shown. */
911 function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
4547 export function ChannelGlyph({ className }: { className?: string }) {
4648 return <Hash size={16} className={className} />;
4749 }
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+}
+22−0
33
44 import { cn } from "../../lib/cn";
55 import { Avatar } from "../ui/avatar";
6+import { Hint } from "../ui/hint";
67
78 /**
89 * A member's face: a person's avatar, or, on an old message from an agent
5152 </span>
5253 );
5354 }
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+}
+144−11
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";
22 import { ContextMenu } from "radix-ui";
33 import { type ReactNode, useEffect, useState } from "react";
44 import { NavLink, useFetcher, useLocation, useNavigate } from "react-router";
55
6−import type { ChatSidebarEntry } from "@g1t/contracts";
6+import type { Channel, ChannelInvitation, ChatSidebarEntry, GuestChannel } from "@g1t/contracts";
77
88 import { conversationCache } from "./conversation-cache";
99 import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
1010 import { DndMenu } from "./dnd-menu";
1111 import { MemberAvatar } from "./marks";
1212 import { PersonStatusEmoji } from "../presence";
13+import { Avatar } from "../ui/avatar";
14+import { Badge } from "../ui/badge";
1315 import { Button } from "../ui/button";
1416 import { Hint } from "../ui/hint";
1517 import { Skeleton } from "../ui/skeleton";
4648 * stay folded on this device. All, Unread and Mentions filter every section. It refreshes
4749 * itself while the tab is shown, so unread counts move without a reload.
4850 */
49−export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
51+export function ChatSidebar({ slug: home, heading = true }: { slug: string; heading?: boolean }) {
5052 const data = useChatData();
53+ // The workspace whose chat is open: another one's, for a guest there.
54+ const slug = data?.slug ?? home;
5155 const { sidebar, refresh } = useChatSidebar();
5256 const send = useChatSend(slug);
5357 const [filter, setFilter] = useState<ChatFilter>("all");
127131 </div>
128132 </div>
129133 )}
134+ {sidebar?.guest && sidebar.workspace && <GuestBanner workspace={sidebar.workspace} home={home} />}
130135 <div className="space-y-2.5 px-2.5 pt-3">
131136 <div className="flex items-center gap-2">
132137 <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">
191196 title="Channels"
192197 folded={folded}
193198 onFold={fold}
194− action={<CreateChannelButton slug={slug} />}
199+ action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />}
195200 keep={channels.filter(keepShown).map(conversation)}
196201 >
197202 {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>}
199204 {filter === "all" && !query && (
200205 <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"}
202207 </ExtraRow>
203208 )}
204209 </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
206228 id="people"
207229 title="Direct messages"
208230 folded={folded}
218240 )}
219241 {dms.map(conversation)}
220242 {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
221− </Section>
243+ </Section>)}
222244 {shown.length === 0 && (filter !== "all" || query) && (
223245 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
224246 )}
406428 entry.channel.kind === "dm" && other ? (
407429 // One person: their dot, cut out of the sidebar behind it.
408430 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
409− ) : entry.channel.private ? (
410− <Lock size={14} />
411431 ) : (
412− <Hash size={15} />
432+ <ChannelMark channel={entry.channel} />
413433 );
414434 return (
415435 <li className="group/row relative">
438458 );
439459 }
440460
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&apos;re not a member of this workspace. You see only the channels you&apos;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+
441574 function SidebarSkeleton() {
442575 return (
443576 <div className="space-y-2 px-2 pt-1" aria-busy="true">
+12−0
4949 return people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id }));
5050 }
5151
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+
5264 /** The cookie that remembers the last conversation open, so Chat opens there again. */
5365 export const LAST_CHAT_COOKIE = "g1t_chat";
5466
+7−2
110110 assert.equal(modeOf("/acme/-/workspace", "acme"), "workspace");
111111 assert.equal(modeOf("/acme/web/pulls", "acme"), "code");
112112 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");
115116 assert.equal(modeHome("home", "acme"), "/acme/-/home");
116117 assert.equal(modeHome("notifications", "acme"), "/notifications");
117118 assert.equal(modeHome("people", "acme"), "/acme/-/people");
195196 assert.equal(shellMode("/acme/web", viewer), "code");
196197 assert.equal(shellMode("/friend/notes", viewer), "code");
197198 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");
198203 assert.equal(shellMode("/policies", viewer), "site");
199204 });
+4−0
291291 return PAGE_MODES[parts[2] ?? ""] ?? "code";
292292 }
293293 }
294+ // Another workspace's chat, where the viewer is a guest: Chat all the same.
295+ if (parts[1] === "-" && parts[2] === "chat") return "chat";
294296 return "code";
295297 }
296298
372374 // g1t's own pages, and files such as robots.txt.
373375 if (NOT_AN_OWNER.has(first) || first.includes(".")) return null;
374376 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;
375379 const repo = parts[1] && parts[1] !== "-" ? parts[1] : null;
376380 if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null;
377381 return { owner: { kind: "workspace", name: parts[0]!, repo } };
+3−3
9191 <section className={SECTION}>
9292 <h2 className="font-medium">Creating channels</h2>
9393 <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{" "}
9595 {settings.private_channels === "owners" ? "only owners" : "any member"} a private one.{" "}
9696 {owner ? (
9797 <>
156156 <section className={SECTION}>
157157 <h2 className="font-medium">Default channels</h2>
158158 <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
160160 moved.
161161 </p>
162162 {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>
164164 ) : (
165165 <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
166166 {view.channels.map((channel) => (
+33−14
1−import { data } from "react-router";
2−
31 import { type AskPage, type Result, parsePrincipalKey } from "@g1t/contracts";
42
53 import type { Route } from "./+types/api";
64 import { RESERVED_CHANNEL_NAMES, channelName, channelPath } from "../../../lib/chat";
7−import { principalsFrom } from "../../../lib/chat.server";
5+import { personFrom, principalsFrom } from "../../../lib/chat.server";
86 import { chat } from "../../../lib/services.server";
9−import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
7+import { assertSameOrigin, requireUser } from "../../../lib/session.server";
108
119 /**
1210 * What a chat page asks for as it runs, as JSON, without loading a page:
1513 * sends: messages, read marks, stars, new channels and direct messages,
1614 * and the actions pressed on cards.
1715 * 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.
1918 */
2019 export async function loader({ params, context, request }: Route.LoaderArgs) {
2120 const viewer = requireUser(context, request);
22− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2321 const slug = params.owner.toLowerCase();
2422 const url = new URL(request.url);
2523 const channel = url.searchParams.get("channel");
5755 archived?: boolean;
5856 /** A question for @g1t asked from a page (the top bar's Ask): where they were. */
5957 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;
6065 };
6166
6267 export async function action({ params, context, request }: Route.ActionArgs) {
6368 assertSameOrigin(request);
6469 const viewer = requireUser(context, request);
65− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
6670 const slug = params.owner.toLowerCase();
6771 const sent = (await request.json().catch(() => ({}))) as Sent;
6872 const channel = sent.channel_id ?? "";
106110 case "leave":
107111 return chat.leave(slug, channel, viewer);
108112 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.
111115 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." } };
114118 return chat.invite(slug, channel, viewer, principal);
115119 }
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);
116133 case "create_channel": {
117134 const name = channelName(sent.name ?? "");
118135 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
119136 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
120137 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
121138 }
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 });
123141 return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
124142 }
125143 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" } = {};
128146 if (typeof sent.name === "string") {
129147 const name = channelName(sent.name);
130148 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
135153 }
136154 if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null;
137155 if (typeof sent.archived === "boolean") change.archived = sent.archived;
156+ if (sent.visibility === "workspace" || sent.visibility === "public") change.visibility = sent.visibility;
138157 const updated = await chat.updateChannel(slug, channel, viewer, change);
139158 return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated;
140159 }
+21−15
1−import { Archive, Hash, Lock, Search } from "lucide-react";
1+import { Archive, Hash, Search } from "lucide-react";
22 import { useMemo, useState } from "react";
33 import { Link, useNavigate } from "react-router";
44
55 import type { Route } from "./+types/browse";
66 import { ChatUnavailable } from "../../../components/chat/empty";
7+import { ChannelMark } from "../../../components/chat/sidebar";
78 import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions";
89 import { channelPath } from "../../../lib/chat";
910 import { sidebarOrNull } from "../../../lib/chat.server";
1819 }
1920
2021 /**
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.
2426 */
2527 export async function loader({ params, context, request }: Route.LoaderArgs) {
2628 const viewer = requireUser(context, request);
2931 const [listed, sidebar] = await Promise.all([chat.browse(slug, viewer, { archived }).catch(() => null), sidebarOrNull(slug, viewer)]);
3032 if (!listed?.ok) return { slug, unavailable: true as const };
3133 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 };
3335 }
3436
3537 export default function Browse({ loaderData }: Route.ComponentProps) {
4951 <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12">
5052 <div className="flex flex-wrap items-center justify-between gap-3">
5153 <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>
5357 <p className="mt-1 text-sm text-muted">
5458 {loaderData.archived
5559 ? "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.`}
5763 </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>}
5965 </div>
6066 <div className="flex items-center gap-2">
6167 <Link
6571 {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />}
6672 {loaderData.archived ? "Current channels" : "Archived"}
6773 </Link>
68− {!loaderData.archived && <CreateChannelButton slug={loaderData.slug} variant="button" />}
74+ {!loaderData.archived && !loaderData.guest && <CreateChannelButton slug={loaderData.slug} variant="button" />}
6975 </div>
7076 </div>
7177 <Card asChild radius="lg" className="mt-6 flex h-10 items-center gap-2 px-3 text-sm focus-within:border-accent-dim">
97103 key={channel.id}
98104 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"
99105 >
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>
105109 <div className="min-w-0 grow">
106110 <Link
107111 to={channelPath(loaderData.slug, channel)}
109113 >
110114 {channel.name}
111115 </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+ )}
113119 {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
114120 </div>
115121 {loaderData.archived ? (
+44−8
22 import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router";
33
44
5−import type { Channel, ChannelMember, ChatMessage, User } from "@g1t/contracts";
5+import type { Channel, ChannelAccessRequest, ChannelDetail, ChannelInvitation, ChannelMember, ChatMessage, User } from "@g1t/contracts";
66
77 import type { Route } from "./+types/channel";
88 import { ChannelView } from "../../../components/chat/channel";
99 import { conversationCache } from "../../../components/chat/conversation-cache";
10−import { ChatUnavailable } from "../../../components/chat/empty";
10+import { ChatUnavailable, RequestAccess } from "../../../components/chat/empty";
1111 import { channelPath } from "../../../lib/chat";
1212 import { rememberChat } from "../../../lib/chat.server";
1313 import { page } from "../../../lib/meta";
14−import { chat } from "../../../lib/services.server";
14+import { chat, identity } from "../../../lib/services.server";
1515 import { requireUser, roleIn } from "../../../lib/session.server";
1616
1717 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
2020 }
2121
2222 export type ChannelData =
23− | { unavailable: true; title: string }
2423 | {
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+ | {
2534 unavailable: false;
2635 title: string;
2736 channel: Channel;
3241 joined: boolean;
3342 /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */
3443 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[];
3553 };
3654
3755 /**
4159 */
4260 export async function loader({ params, context, request }: Route.LoaderArgs) {
4361 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;
4564 const slug = params.owner.toLowerCase();
4665 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 });
4868 if ("id" in params && params.id === "g1t") {
4969 const opened = await chat.g1tDm(slug, viewer).catch(() => null);
5070 if (opened?.ok) throw redirect(channelPath(slug, opened.value));
5676 return "unavailable" as const;
5777 },
5878 );
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+ }
6089 if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
6190 const secure = new URL(request.url).protocol === "https:";
6291 return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
92121 older: history.ok ? history.value.older : null,
93122 joined,
94123 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 ?? [],
95131 };
96132 }
97133
133169 useEffect(() => {
134170 if (!loaderData.unavailable) cache.set(window.location.pathname, loaderData);
135171 }, [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} />;
137173 return <ChannelView key={loaderData.channel.id} data={loaderData} />;
138174 }
+7−4
1414 */
1515 export type ChatLayoutData = {
1616 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;
1919 /** The viewer, named as chat names them (`memberName`). */
2020 me: { id: string; username: string; display_username: string | null; display_name: string; avatar: string | null };
2121 sidebar: ChatSidebar | null;
2525 export async function loader({ params, context, request }: Route.LoaderArgs): Promise<ChatLayoutData> {
2626 const viewer = requireUser(context, request);
2727 const role = roleIn(viewer, params.owner);
28− if (!role) throw data(null, { status: 404 });
2928 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 });
3134 const self = people.find((person) => person.name === viewer.username.toLowerCase());
3235 const display_username = viewer.display_username ?? self?.display_username ?? null;
3336 return {
+1−2
33 import { CHAT_VIEWER_HEADER } from "@g1t/contracts";
44
55 import type { Route } from "./+types/live";
6−import { roleIn } from "../../../lib/session.server";
76 import { socketViewer } from "../../../lib/socket-ticket.server";
87
98 /**
1716 // A session, or a page opened with a token by its socket ticket.
1817 const viewer = await socketViewer(context, request);
1918 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.
2120 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
2221 return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } });
2322 }
+2−4
1−import { data } from "react-router";
2−
31 import type { Route } from "./+types/person";
42 import { buildCard } from "../../../lib/hovercard";
53 import { identity, repos } from "../../../lib/services.server";
6−import { requireUser, roleIn } from "../../../lib/session.server";
4+import { requireUser } from "../../../lib/session.server";
75
86 /**
97 * A person's card in Chat (components/chat/profile-card.tsx): their public
1210 */
1311 export async function loader({ params, context, request }: Route.LoaderArgs) {
1412 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.
1614 const [card, teams] = await Promise.all([
1715 buildCard(
1816 params.username,
+3−3
309309 <input type="hidden" name="intent" value="chat-channels" />
310310 <div className="grid gap-4 sm:grid-cols-2">
311311 <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">
314314 <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." />
316316 </RadioGroup>
317317 </div>
318318 <div>
+137−8
8484 archived_by?: string | null;
8585 /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */
8686 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;
8794 };
8895
8996 /**
107114
108115 export type ChannelKind = "channel" | "dm";
109116
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+
110124 export type Channel = {
111125 id: string;
112126 workspace_id: string;
114128 /** Lowercase, no `#`. Null for a direct message. */
115129 name: string | null;
116130 topic: string | null;
131+ /** `visibility === "private"`, kept for older clients. */
117132 private: boolean;
133+ visibility: ChannelVisibility;
118134 created_by: Principal;
119135 created_at: string;
120136 archived_at: string | null;
321337 add_emoji: boolean;
322338 /** Whether they may change the workspace's chat settings: owners only. */
323339 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;
324348 };
325349
326350 /** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */
330354 channels: Channel[];
331355 };
332356
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" };
335363
336364 /** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */
337365 export type EmojiFile = { data: string };
355383
356384 export type ChatSidebar = {
357385 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. */
359387 browsable: number;
360388 /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */
361389 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;
362423 };
363424
364425 /**
396457 newer?: string | null;
397458 };
398459
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 };
400465
401466 export type PostMessage = {
402467 body: string;
438503 */
439504 export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
440505
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+};
443542
444543 export type ChatApi = {
445544 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
481580 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
482581 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
483582 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>>;
486583 /**
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+ /**
487609 * Newest first. Without `thread_root`, the channel's top-level messages;
488610 * with it, that thread's replies, plus the message they reply to as the
489611 * oldest once the page reaches the start of the thread (`older` null).
605727 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
606728 invite: (workspace, channelId, viewer, member) =>
607729 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 }),
608737 messages: (workspace, channelId, viewer, page) =>
609738 call("messages", {
610739 workspace,
+1−0
183183 userForSshKey: (fingerprint) => call("user_for_ssh_key", { fingerprint }),
184184 userByUsername: (username) => call("user_by_username", { username }),
185185 usernames: (ids) => call("usernames", { ids }),
186+ accounts: (ids) => call("accounts", { ids }),
186187 usersForAudience: (ids) => call("users_for_audience", { ids }),
187188 profile: (username) => call("profile", { username }),
188189 updateProfile: (actor, fields) => call("update_profile", { actor, ...fields }),
+3−1
88 import type { TeamCreation, TeamsClient } from "./teams";
99 import type { PeopleClient } from "./people";
1010 import type { DeployKeysClient } from "./deploy-keys";
11−import type { EmailConfirmed } from "./accounts";
11+import type { EmailConfirmed, EmailOwner } from "./accounts";
1212
1313 export type User = {
1414 id: string;
925925 userByUsername(username: string): Promise<Viewer>;
926926 /** The names behind account and workspace ids; unknown ids are left out. */
927927 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>>;
928930 /**
929931 * Internal: the people behind these ids (at most 50) with their
930932 * workspaces, roles and repository grants, as a signed-in viewer has
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
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";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+58−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+);
+80−0
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+});
+88−0
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+}
+7−1
Loading the diff…
+521−49
Loading the diff…

Comments

Sign in to comment.