mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add private channel support to UI (#121)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+47
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user