fix(sidebar): non-selectable channel names + copy/leave context menu actions (#1260)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-25 14:25:50 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent f072032aaf
commit 4481f8fd5a
4 changed files with 140 additions and 11 deletions
@@ -28,6 +28,7 @@ import {
CreateSectionDialog,
DeleteSectionAlertDialog,
RenameSectionDialog,
useLeaveChannelDialog,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
@@ -380,6 +381,8 @@ export function AppSidebar({
React.useState<ChannelSection | null>(null);
const [deleteSectionTarget, setDeleteSectionTarget] =
React.useState<ChannelSection | null>(null);
const { requestLeaveChannel, dialog: leaveChannelDialog } =
useLeaveChannelDialog();
const sectionIds = React.useMemo(
() => channelSections.map((s) => s.id),
@@ -674,6 +677,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
) : null}
<SidebarDndContext
@@ -727,6 +731,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
))}
<ChannelGroupSection
@@ -760,6 +765,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
</SidebarDndContext>
<FeatureGate feature="forum">
@@ -973,6 +979,7 @@ export function AppSidebar({
setDeleteSectionTarget(null);
}}
/>
{leaveChannelDialog}
<SidebarRail />
</Sidebar>
);
@@ -20,6 +20,8 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import type { Channel } from "@/shared/api/types";
import { useLeaveChannelMutation } from "@/features/channels/hooks";
type SectionNameDialogProps = {
open: boolean;
@@ -192,3 +194,70 @@ export function DeleteSectionAlertDialog({
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// LeaveChannelAlertDialog
// ---------------------------------------------------------------------------
export type LeaveChannelAlertDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
channelName: string;
onConfirm: () => void;
};
export function LeaveChannelAlertDialog({
open,
onOpenChange,
channelName,
onConfirm,
}: LeaveChannelAlertDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave channel</AlertDialogTitle>
<AlertDialogDescription>
{`Leave "${channelName}"? You'll stop receiving its messages and can rejoin later.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
Leave
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// useLeaveChannelDialog — owns leave-channel state, mutation, and dialog
// ---------------------------------------------------------------------------
export function useLeaveChannelDialog() {
const [target, setTarget] = React.useState<Channel | null>(null);
const leaveChannel = useLeaveChannelMutation(target?.id ?? null);
const dialog = (
<LeaveChannelAlertDialog
open={target !== null}
onOpenChange={(open) => {
if (!open) setTarget(null);
}}
channelName={target?.name ?? ""}
onConfirm={() => {
if (target) {
leaveChannel.mutate();
}
setTarget(null);
}}
/>
);
return { requestLeaveChannel: setTarget, dialog };
}
@@ -8,7 +8,10 @@ import {
CheckCircle2,
ChevronDown,
CircleDot,
Clipboard,
Copy,
GripVertical,
LogOut,
Pencil,
Plus,
Star,
@@ -16,6 +19,8 @@ import {
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -105,6 +110,17 @@ function MoveToSectionSubmenu({
);
}
function copyToClipboard(text: string, successMessage: string) {
void navigator.clipboard
.writeText(text)
.then(() => {
toast.success(successMessage);
})
.catch(() => {
toast.error("Failed to copy to clipboard");
});
}
export function ChannelContextMenuItems({
channel,
hasUnread,
@@ -121,6 +137,7 @@ export function ChannelContextMenuItems({
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onLeaveChannel,
}: {
channel: Channel;
hasUnread: boolean;
@@ -140,6 +157,7 @@ export function ChannelContextMenuItems({
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
@@ -207,6 +225,35 @@ export function ChannelContextMenuItems({
/>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() =>
copyToClipboard(channel.name, "Channel name copied to clipboard")
}
>
<Copy className="h-4 w-4" />
Copy channel name
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
copyToClipboard(channel.id, "Channel ID copied to clipboard")
}
>
<Clipboard className="h-4 w-4" />
Copy channel ID
</ContextMenuItem>
{onLeaveChannel ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onLeaveChannel(channel)}
>
<LogOut className="h-4 w-4" />
Leave channel
</ContextMenuItem>
</>
) : null}
</>
);
}
@@ -305,6 +352,7 @@ export function ChannelGroupSection({
starredChannelIds,
onStarChannel,
onUnstarChannel,
onLeaveChannel,
}: {
browseAriaLabel?: string;
createAriaLabel: string;
@@ -340,6 +388,7 @@ export function ChannelGroupSection({
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const contentId = `sidebar-${listTestId}`;
@@ -394,6 +443,7 @@ export function ChannelGroupSection({
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
onLeaveChannel={onLeaveChannel}
/>
</ContextMenuContent>
</ContextMenu>
@@ -402,7 +452,9 @@ export function ChannelGroupSection({
) : null;
const sectionContent = (
<SidebarGroup className={cn("group/sidebar-section", groupClassName)}>
<SidebarGroup
className={cn("group/sidebar-section select-none", groupClassName)}
>
<div className="relative">
<SidebarGroupLabel asChild>
<button
@@ -476,6 +528,7 @@ export function CustomChannelSection({
starredChannelIds,
onStarChannel,
onUnstarChannel,
onLeaveChannel,
}: {
section: ChannelSection;
channels: Channel[];
@@ -510,6 +563,7 @@ export function CustomChannelSection({
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const contentId = `sidebar-section-${section.id}`;
@@ -518,7 +572,10 @@ export function CustomChannelSection({
{({ dragHandleProps, isDragging }) => (
<DroppableSectionBody sectionId={section.id}>
<SidebarGroup
className={cn("group/sidebar-section", isDragging && "opacity-30")}
className={cn(
"group/sidebar-section select-none",
isDragging && "opacity-30",
)}
>
<ContextMenu>
<ContextMenuTrigger asChild>
@@ -660,6 +717,7 @@ export function CustomChannelSection({
onCreateSectionForChannel={
onCreateSectionForChannel
}
onLeaveChannel={onLeaveChannel}
/>
</ContextMenuContent>
</ContextMenu>
@@ -323,7 +323,7 @@ export function SidebarSection({
const canToggle = Boolean(onToggleCollapsed);
return (
<SidebarGroup className="group/sidebar-section">
<SidebarGroup className="group/sidebar-section select-none">
<div className="relative">
<SidebarGroupLabel asChild={canToggle}>
{canToggle ? (
@@ -410,12 +410,9 @@ export function SidebarSection({
</SidebarMenuItem>
);
const hasContextAction =
(unreadChannelIds.has(channel.id) && onMarkChannelRead) ||
(!unreadChannelIds.has(channel.id) && onMarkChannelUnread) ||
(onMuteChannel && onUnmuteChannel);
return hasContextAction ? (
// The shared menu always renders copy actions, so every row
// gets a context menu regardless of read/mute availability.
return (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
<ContextMenuContent>
@@ -430,8 +427,6 @@ export function SidebarSection({
/>
</ContextMenuContent>
</ContextMenu>
) : (
menuItem
);
})}
</SidebarMenu>