mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
f072032aaf
commit
4481f8fd5a
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user