From a416e0dfed8d782c2123e185cb61ee8f62790e6b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 17:30:07 -0700 Subject: [PATCH 1/2] feat(sidebar): non-selectable channel names + copy/leave context menu actions Add select-none to the sidebar channel-name span so dragging no longer highlights names. Extend the shared channel context menu with Copy channel name, Copy channel ID, and a destructive Leave channel action (standard shadcn confirm dialog). Leave logic extracted into a useLeaveChannelDialog hook to keep AppSidebar under the file-size limit. Copy actions render unconditionally; leave is gated on the onLeaveChannel prop and wired only to stream sections. Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/sidebar/ui/AppSidebar.tsx | 7 ++ .../sidebar/ui/ChannelSectionDialogs.tsx | 69 +++++++++++++++++++ .../sidebar/ui/CustomChannelSection.tsx | 53 ++++++++++++++ .../features/sidebar/ui/SidebarSection.tsx | 15 ++-- 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 638eed2ad..4076bc1e1 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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"; @@ -388,6 +389,8 @@ export function AppSidebar({ React.useState(null); const [deleteSectionTarget, setDeleteSectionTarget] = React.useState(null); + const { requestLeaveChannel, dialog: leaveChannelDialog } = + useLeaveChannelDialog(); const sectionIds = React.useMemo( () => channelSections.map((s) => s.id), @@ -682,6 +685,7 @@ export function AppSidebar({ starredChannelIds={starredChannelIds} onStarChannel={onStarChannel} onUnstarChannel={onUnstarChannel} + onLeaveChannel={requestLeaveChannel} /> ) : null} ))} @@ -981,6 +987,7 @@ export function AppSidebar({ setDeleteSectionTarget(null); }} /> + {leaveChannelDialog} ); diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx index c25f14ddf..f94867302 100644 --- a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx +++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx @@ -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"; // --------------------------------------------------------------------------- // SectionNameDialog (internal) @@ -208,3 +210,70 @@ export function DeleteSectionAlertDialog({ ); } + +// --------------------------------------------------------------------------- +// LeaveChannelAlertDialog +// --------------------------------------------------------------------------- + +export type LeaveChannelAlertDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + channelName: string; + onConfirm: () => void; +}; + +export function LeaveChannelAlertDialog({ + open, + onOpenChange, + channelName, + onConfirm, +}: LeaveChannelAlertDialogProps) { + return ( + + + + Leave channel + + {`Leave "${channelName}"? You'll stop receiving its messages and can rejoin later.`} + + + + Cancel + + Leave + + + + + ); +} + +// --------------------------------------------------------------------------- +// useLeaveChannelDialog — owns leave-channel state, mutation, and dialog +// --------------------------------------------------------------------------- + +export function useLeaveChannelDialog() { + const [target, setTarget] = React.useState(null); + const leaveChannel = useLeaveChannelMutation(target?.id ?? null); + + const dialog = ( + { + if (!open) setTarget(null); + }} + channelName={target?.name ?? ""} + onConfirm={() => { + if (target) { + leaveChannel.mutate(); + } + setTarget(null); + }} + /> + ); + + return { requestLeaveChannel: setTarget, dialog }; +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index f4a0add50..953e7433b 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -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, @@ -117,6 +122,17 @@ function MoveToSectionSubmenu({ // ChannelContextMenuItems — shared context menu items for channel rows // --------------------------------------------------------------------------- +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, @@ -133,6 +149,7 @@ export function ChannelContextMenuItems({ onAssignChannel, onUnassignChannel, onCreateSectionForChannel, + onLeaveChannel, }: { channel: Channel; hasUnread: boolean; @@ -152,6 +169,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 @@ -219,6 +237,35 @@ export function ChannelContextMenuItems({ /> ) : null} + + + copyToClipboard(channel.name, "Channel name copied to clipboard") + } + > + + Copy channel name + + + copyToClipboard(channel.id, "Channel ID copied to clipboard") + } + > + + Copy channel ID + + {onLeaveChannel ? ( + <> + + onLeaveChannel(channel)} + > + + Leave channel + + + ) : null} ); } @@ -325,6 +372,7 @@ export function ChannelGroupSection({ starredChannelIds, onStarChannel, onUnstarChannel, + onLeaveChannel, }: { browseAriaLabel?: string; createAriaLabel: string; @@ -360,6 +408,7 @@ export function ChannelGroupSection({ starredChannelIds?: ReadonlySet; onStarChannel?: (channelId: string) => void; onUnstarChannel?: (channelId: string) => void; + onLeaveChannel?: (channel: Channel) => void; }) { const contentId = `sidebar-${listTestId}`; @@ -414,6 +463,7 @@ export function ChannelGroupSection({ onAssignChannel={onAssignChannel} onUnassignChannel={onUnassignChannel} onCreateSectionForChannel={onCreateSectionForChannel} + onLeaveChannel={onLeaveChannel} /> @@ -500,6 +550,7 @@ export function CustomChannelSection({ starredChannelIds, onStarChannel, onUnstarChannel, + onLeaveChannel, }: { section: ChannelSection; channels: Channel[]; @@ -534,6 +585,7 @@ export function CustomChannelSection({ starredChannelIds?: ReadonlySet; onStarChannel?: (channelId: string) => void; onUnstarChannel?: (channelId: string) => void; + onLeaveChannel?: (channel: Channel) => void; }) { const contentId = `sidebar-section-${section.id}`; @@ -684,6 +736,7 @@ export function CustomChannelSection({ onCreateSectionForChannel={ onCreateSectionForChannel } + onLeaveChannel={onLeaveChannel} /> diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1ceba1720..e073bd4fe 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -232,7 +232,9 @@ export function ChannelMenuButton({ dmParticipants={dmParticipants} presenceStatus={presenceStatus} /> - {resolvedLabel} + + {resolvedLabel} + {ephemeralDisplay ? ( ); - 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 ( {menuItem} @@ -425,8 +424,6 @@ export function SidebarSection({ /> - ) : ( - menuItem ); })} From d0395e78e0e6ff331db4f0465585203ed85eeaf1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 18:02:23 -0700 Subject: [PATCH 2/2] fix(sidebar): make all sidebar section text non-selectable Hoist select-none onto the three sidebar SidebarGroup containers so section/group headers, unread count + dot badges, and DM participant-count badges no longer highlight on cursor-drag. Drops the now-redundant per-span select-none on the channel-name span (the container covers it everywhere ChannelMenuButton renders). Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/sidebar/ui/CustomChannelSection.tsx | 9 +++++++-- desktop/src/features/sidebar/ui/SidebarSection.tsx | 6 ++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 953e7433b..7abb40a25 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -472,7 +472,9 @@ export function ChannelGroupSection({ ) : null; const sectionContent = ( - +