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.

Public channels, guests and requests to join in Chat (#44) #46

Closedsyntaqx wants to merge public-channels into main
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โ€ฆ