feat(desktop): add private channel support to UI (#121)

This commit is contained in:
Wes
2026-03-19 17:29:48 -07:00
committed by GitHub
parent ac5d84b576
commit 12cfc64ec2
6 changed files with 161 additions and 8 deletions
+1
View File
@@ -21,6 +21,7 @@
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
+47
View File
@@ -11,6 +11,9 @@ importers:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -502,6 +505,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -805,6 +821,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
@@ -2280,6 +2305,22 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@@ -2583,6 +2624,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@radix-ui/rect': 1.1.1
+4 -4
View File
@@ -531,22 +531,22 @@ export function AppShell() {
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name }) => {
onCreateChannel={async ({ description, name, visibility }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility: "open",
visibility,
});
openChannelView(createdChannel.id);
}}
onCreateForum={async ({ description, name }) => {
onCreateForum={async ({ description, name, visibility }) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility: "open",
visibility,
});
openChannelView(createdForum.id);
+76 -3
View File
@@ -1,5 +1,5 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Bot, Home, PenSquare, Plus, Search } from "lucide-react";
import { Bot, Home, Lock, PenSquare, Plus, Search } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
@@ -12,8 +12,14 @@ import {
SidebarSection,
} from "@/features/sidebar/ui/SidebarSection";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import type { Channel, PresenceStatus, Profile } from "@/shared/api/types";
import type {
Channel,
ChannelVisibility,
PresenceStatus,
Profile,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Input } from "@/shared/ui/input";
import {
Sidebar,
@@ -61,10 +67,12 @@ type AppSidebarProps = {
onCreateChannel: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
}) => Promise<void>;
onCreateForum: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
}) => Promise<void>;
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
@@ -81,12 +89,18 @@ type AppSidebarProps = {
// ---------------------------------------------------------------------------
function useCreateForm(
onCreate: (input: { name: string; description?: string }) => Promise<void>,
onCreate: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
}) => Promise<void>,
entityLabel: string,
) {
const [isOpen, setIsOpen] = React.useState(false);
const [draftName, setDraftName] = React.useState("");
const [draftDescription, setDraftDescription] = React.useState("");
const [draftVisibility, setDraftVisibility] =
React.useState<ChannelVisibility>("open");
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -105,6 +119,7 @@ function useCreateForm(
setErrorMessage(undefined);
setDraftName("");
setDraftDescription("");
setDraftVisibility("open");
setIsOpen(false);
}
@@ -118,6 +133,11 @@ function useCreateForm(
setDraftDescription(value);
}
function changeVisibility(value: ChannelVisibility) {
setErrorMessage(undefined);
setDraftVisibility(value);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -133,10 +153,12 @@ function useCreateForm(
await onCreate({
name,
description: description || undefined,
visibility: draftVisibility,
});
setDraftName("");
setDraftDescription("");
setDraftVisibility("open");
setIsOpen(false);
} catch (error) {
setErrorMessage(
@@ -151,12 +173,14 @@ function useCreateForm(
isOpen,
draftName,
draftDescription,
draftVisibility,
errorMessage,
inputRef,
toggle,
cancel,
changeName,
changeDescription,
changeVisibility,
handleSubmit,
};
}
@@ -212,6 +236,43 @@ function SectionHeaderActions({
);
}
// ---------------------------------------------------------------------------
// PrivateCheckbox — checkbox toggle for channel visibility
// ---------------------------------------------------------------------------
function PrivateCheckbox({
disabled,
isPrivate,
onChange,
testId,
}: {
disabled: boolean;
isPrivate: boolean;
onChange: (isPrivate: boolean) => void;
testId: string;
}) {
const id = React.useId();
return (
<div className="flex items-center gap-2">
<Checkbox
checked={isPrivate}
data-testid={testId}
disabled={disabled}
id={id}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<label
className="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-sidebar-foreground/70 select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
htmlFor={id}
>
<Lock className="h-3 w-3" />
Private channel
</label>
</div>
);
}
// ---------------------------------------------------------------------------
// ChannelGroupSection — unified Channels / Forums section
// ---------------------------------------------------------------------------
@@ -224,6 +285,7 @@ function ChannelGroupSection({
createFormTestId,
createNameTestId,
createDescriptionTestId,
createVisibilityTestId,
groupClassName,
isActiveChannel,
isCreating,
@@ -245,6 +307,7 @@ function ChannelGroupSection({
createFormTestId: string;
createNameTestId: string;
createDescriptionTestId: string;
createVisibilityTestId: string;
groupClassName?: string;
isActiveChannel: boolean;
isCreating: boolean;
@@ -302,6 +365,14 @@ function ChannelGroupSection({
placeholder={descriptionPlaceholder}
value={form.draftDescription}
/>
<PrivateCheckbox
disabled={isCreating}
isPrivate={form.draftVisibility === "private"}
onChange={(isPrivate) =>
form.changeVisibility(isPrivate ? "private" : "open")
}
testId={createVisibilityTestId}
/>
<div className="flex items-center gap-2">
<Button
disabled={isCreating || form.draftName.trim().length === 0}
@@ -513,6 +584,7 @@ export function AppSidebar({
createFormTestId="create-stream-form"
createNameTestId="create-stream-name"
createDescriptionTestId="create-stream-description"
createVisibilityTestId="create-stream-visibility"
descriptionPlaceholder="What this stream is for"
form={streamForm}
groupClassName="pt-1"
@@ -535,6 +607,7 @@ export function AppSidebar({
createFormTestId="create-forum-form"
createNameTestId="create-forum-name"
createDescriptionTestId="create-forum-description"
createVisibilityTestId="create-forum-visibility"
descriptionPlaceholder="What this forum is for"
form={forumForm}
isActiveChannel={selectedView === "channel"}
@@ -1,5 +1,5 @@
import type * as React from "react";
import { CircleDot, FileText, Hash } from "lucide-react";
import { CircleDot, FileText, Hash, Lock } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel, PresenceStatus } from "@/shared/api/types";
@@ -109,6 +109,10 @@ function SidebarChannelIcon({
);
}
if (channel.visibility === "private") {
return <Lock className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/shared/lib/cn";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };