mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): unify sidebar section actions into a per-section ⋮ menu (#1577)
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
import { MessageCirclePlus } from "lucide-react";
|
||||
|
||||
import * as React from "react";
|
||||
import { FeatureGate } from "@/shared/features";
|
||||
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||
@@ -36,8 +34,9 @@ import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
|
||||
import {
|
||||
ChannelGroupSection,
|
||||
CustomChannelSection,
|
||||
SectionActionsMenu,
|
||||
SectionQuickAction,
|
||||
} from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import { ChannelSortDropdown } from "@/features/sidebar/ui/ChannelSortDropdown";
|
||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||
@@ -47,10 +46,6 @@ import {
|
||||
SidebarLoadingContent,
|
||||
useSidebarLoadingShape,
|
||||
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
|
||||
import {
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
} from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
|
||||
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
|
||||
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
|
||||
@@ -592,7 +587,6 @@ export function AppSidebar({
|
||||
<>
|
||||
{starredChannels.length > 0 ? (
|
||||
<ChannelGroupSection
|
||||
createAriaLabel="Starred channels"
|
||||
hasUnread={starredChannels.some((c) =>
|
||||
unreadChannelIds.has(c.id),
|
||||
)}
|
||||
@@ -600,16 +594,9 @@ export function AppSidebar({
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={starredChannels}
|
||||
leadingHeaderAction={
|
||||
<ChannelSortDropdown
|
||||
groupLabel="starred channels"
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor("starred", mode)
|
||||
}
|
||||
sortMode={sortModeFor("starred")}
|
||||
testId="channel-sort-trigger-starred"
|
||||
/>
|
||||
}
|
||||
sortMode={sortModeFor("starred")}
|
||||
onSortModeChange={(mode) => setSortModeFor("starred", mode)}
|
||||
actionsTestId="section-actions-starred"
|
||||
listTestId="starred-list"
|
||||
onMarkAllRead={() => {
|
||||
for (const channel of starredChannels) {
|
||||
@@ -661,21 +648,9 @@ export function AppSidebar({
|
||||
assignments={channelAssignments}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === channelSections.length - 1}
|
||||
leadingHeaderAction={
|
||||
<ChannelSortDropdown
|
||||
groupLabel={section.name}
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor(
|
||||
sectionSortGroupKey(section.id),
|
||||
mode,
|
||||
)
|
||||
}
|
||||
sortMode={sortModeFor(
|
||||
sectionSortGroupKey(section.id),
|
||||
)}
|
||||
testId={`channel-sort-trigger-section-${section.id}`}
|
||||
visibilityClassName=""
|
||||
/>
|
||||
sortMode={sortModeFor(sectionSortGroupKey(section.id))}
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor(sectionSortGroupKey(section.id), mode)
|
||||
}
|
||||
onToggleCollapsed={() =>
|
||||
toggleCollapsedSection(section.id)
|
||||
@@ -707,27 +682,23 @@ export function AppSidebar({
|
||||
/>
|
||||
))}
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse channels"
|
||||
createAriaLabel="Create a channel"
|
||||
browseLabel="Browse channels"
|
||||
createLabel="New channel"
|
||||
draggable
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.channels}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={sectionBuckets.unassigned}
|
||||
leadingHeaderAction={
|
||||
<ChannelSortDropdown
|
||||
groupLabel="channels"
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor("channels", mode)
|
||||
}
|
||||
sortMode={sortModeFor("channels")}
|
||||
testId="channel-sort-trigger-channels"
|
||||
/>
|
||||
sortMode={sortModeFor("channels")}
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor("channels", mode)
|
||||
}
|
||||
actionsTestId="section-actions-channels"
|
||||
listTestId="stream-list"
|
||||
onBrowseClick={onBrowseChannels}
|
||||
onCreateClick={() => openCreateDialog("stream")}
|
||||
showQuickCreate
|
||||
onMarkAllRead={onMarkAllChannelsRead}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
@@ -753,22 +724,15 @@ export function AppSidebar({
|
||||
</SidebarDndContext>
|
||||
<FeatureGate feature="forum">
|
||||
<ChannelGroupSection
|
||||
createAriaLabel="Create a forum"
|
||||
createLabel="New forum"
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.forums}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={forumChannels}
|
||||
leadingHeaderAction={
|
||||
<ChannelSortDropdown
|
||||
groupLabel="forums"
|
||||
onSortModeChange={(mode) =>
|
||||
setSortModeFor("forums", mode)
|
||||
}
|
||||
sortMode={sortModeFor("forums")}
|
||||
testId="channel-sort-trigger-forums"
|
||||
/>
|
||||
}
|
||||
sortMode={sortModeFor("forums")}
|
||||
onSortModeChange={(mode) => setSortModeFor("forums", mode)}
|
||||
actionsTestId="section-actions-forums"
|
||||
listTestId="forum-list"
|
||||
onCreateClick={() => openCreateDialog("forum")}
|
||||
onMarkAllRead={onMarkAllChannelsRead}
|
||||
@@ -788,25 +752,18 @@ export function AppSidebar({
|
||||
<SidebarSection
|
||||
action={
|
||||
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<ChannelSortDropdown
|
||||
groupLabel="direct messages"
|
||||
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
|
||||
sortMode={sortModeFor("dms")}
|
||||
testId="channel-sort-trigger-dms"
|
||||
<SectionQuickAction
|
||||
label="New message"
|
||||
onClick={() => setIsNewDmOpen(true)}
|
||||
testId="section-actions-dms-quick-create"
|
||||
/>
|
||||
<SectionActionsMenu
|
||||
sectionLabel="direct messages"
|
||||
testId="section-actions-dms"
|
||||
onNewMessage={() => setIsNewDmOpen(true)}
|
||||
sortMode={sortModeFor("dms")}
|
||||
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
|
||||
/>
|
||||
<button
|
||||
aria-expanded={isNewDmOpen}
|
||||
aria-label="Compose new message"
|
||||
className={`${SECTION_ICON_BUTTON_CLASS} ${SECTION_ACTION_VISIBILITY_CLASS}`}
|
||||
data-testid="new-dm-trigger"
|
||||
onClick={() => {
|
||||
setIsNewDmOpen(true);
|
||||
}}
|
||||
title="Compose new message"
|
||||
type="button"
|
||||
>
|
||||
<MessageCirclePlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
dmParticipantsByChannelId={dmParticipantsByChannelId}
|
||||
|
||||
284
desktop/src/features/sidebar/ui/ChannelContextMenu.tsx
Normal file
284
desktop/src/features/sidebar/ui/ChannelContextMenu.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Copy,
|
||||
LogOut,
|
||||
Plus,
|
||||
Star,
|
||||
StarOff,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
|
||||
import {
|
||||
ContextMenuIconSlot,
|
||||
deferMenuAction,
|
||||
} from "@/features/sidebar/ui/sidebarMenuHelpers";
|
||||
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
|
||||
function MoveToSectionSubmenu({
|
||||
channelId,
|
||||
sections,
|
||||
assignments,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
}: {
|
||||
channelId: string;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
onAssignChannel: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel: (channelId: string) => void;
|
||||
onCreateSectionForChannel: (channelId: string) => void;
|
||||
}) {
|
||||
const currentSectionId = assignments[channelId];
|
||||
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ContextMenuIconSlot />
|
||||
<span>Move to section</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{sections.map((section) => (
|
||||
<ContextMenuItem
|
||||
key={section.id}
|
||||
onSelect={() =>
|
||||
deferMenuAction(() => onAssignChannel(channelId, section.id))
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
{currentSectionId === section.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : section.icon ? (
|
||||
<StatusEmoji className="h-4 w-4" value={section.icon} />
|
||||
) : null}
|
||||
</ContextMenuIconSlot>
|
||||
<span>{section.name}</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{sections.length > 0 ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() => onCreateSectionForChannel(channelId))
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<Plus className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>New section...</span>
|
||||
</ContextMenuItem>
|
||||
{currentSectionId ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() => deferMenuAction(() => onUnassignChannel(channelId))}
|
||||
>
|
||||
<ContextMenuIconSlot />
|
||||
<span>Remove from section</span>
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel/DM context menu's Copy actions, grouped under a single
|
||||
* "Copy" submenu (channel name / channel ID).
|
||||
*/
|
||||
function CopyChannelSubmenu({ channel }: { channel: Channel }) {
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ContextMenuIconSlot>
|
||||
<Copy className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Copy</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
copyTextToClipboard(
|
||||
channel.name,
|
||||
"Channel name copied to clipboard",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Copy channel name</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
copyTextToClipboard(channel.id, "Channel ID copied to clipboard")
|
||||
}
|
||||
>
|
||||
<span>Copy channel ID</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelContextMenuItems({
|
||||
channel,
|
||||
hasUnread,
|
||||
isMuted,
|
||||
isStarred,
|
||||
sections,
|
||||
assignments,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onMuteChannel,
|
||||
onUnmuteChannel,
|
||||
onStarChannel,
|
||||
onUnstarChannel,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
onLeaveChannel,
|
||||
}: {
|
||||
channel: Channel;
|
||||
hasUnread: boolean;
|
||||
isMuted?: boolean;
|
||||
isStarred?: boolean;
|
||||
sections?: ChannelSection[];
|
||||
assignments?: Record<string, string>;
|
||||
onMarkChannelRead?: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread?: (channelId: string) => void;
|
||||
onMuteChannel?: (channelId: string) => void;
|
||||
onUnmuteChannel?: (channelId: string) => void;
|
||||
onStarChannel?: (channelId: string) => void;
|
||||
onUnstarChannel?: (channelId: string) => void;
|
||||
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
|
||||
? Boolean(onMarkChannelRead)
|
||||
: Boolean(onMarkChannelUnread);
|
||||
const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel);
|
||||
const showMove = Boolean(
|
||||
sections &&
|
||||
assignments &&
|
||||
onAssignChannel &&
|
||||
onUnassignChannel &&
|
||||
onCreateSectionForChannel,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CopyChannelSubmenu channel={channel} />
|
||||
{showMove ? (
|
||||
<MoveToSectionSubmenu
|
||||
channelId={channel.id}
|
||||
sections={sections ?? []}
|
||||
assignments={assignments ?? {}}
|
||||
onAssignChannel={onAssignChannel ?? (() => {})}
|
||||
onUnassignChannel={onUnassignChannel ?? (() => {})}
|
||||
onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
{showReadToggle ? <ContextMenuSeparator /> : null}
|
||||
{hasUnread && onMarkChannelRead ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() =>
|
||||
onMarkChannelRead(channel.id, channel.lastMessageAt),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Mark as read</span>
|
||||
</ContextMenuItem>
|
||||
) : !hasUnread && onMarkChannelUnread ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() => onMarkChannelUnread(channel.id))
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Mark unread</span>
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{showMuteToggle || showStar ? <ContextMenuSeparator /> : null}
|
||||
{showMuteToggle ? (
|
||||
isMuted ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() => onUnmuteChannel?.(channel.id))
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<Bell className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Unmute channel</span>
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
onSelect={() => deferMenuAction(() => onMuteChannel?.(channel.id))}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<BellOff className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Mute channel</span>
|
||||
</ContextMenuItem>
|
||||
)
|
||||
) : null}
|
||||
{showStar ? (
|
||||
isStarred ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() => onUnstarChannel?.(channel.id))
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<StarOff className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Unstar channel</span>
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
onSelect={() => deferMenuAction(() => onStarChannel?.(channel.id))}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<Star className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Star channel</span>
|
||||
</ContextMenuItem>
|
||||
)
|
||||
) : null}
|
||||
{onLeaveChannel ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => deferMenuAction(() => onLeaveChannel(channel))}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Leave channel</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
|
||||
import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference";
|
||||
import {
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
} from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
|
||||
const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [
|
||||
{ value: "recent", label: "Recent" },
|
||||
{ value: "alpha", label: "A–Z" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Section-header dropdown for a single sidebar grouping's sort preference.
|
||||
* Every grouping (Starred, each custom section, Channels, Forums, DMs)
|
||||
* carries its own control and saved mode; grouping boundaries are untouched.
|
||||
*/
|
||||
export function ChannelSortDropdown({
|
||||
groupLabel,
|
||||
sortMode,
|
||||
onSortModeChange,
|
||||
testId,
|
||||
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
|
||||
}: {
|
||||
groupLabel: string;
|
||||
sortMode: ChannelSortMode;
|
||||
onSortModeChange: (mode: ChannelSortMode) => void;
|
||||
testId?: string;
|
||||
visibilityClassName?: string;
|
||||
}) {
|
||||
const activeLabel =
|
||||
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "A–Z";
|
||||
const ariaLabel = `Sort ${groupLabel}: ${activeLabel}`;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
|
||||
data-testid={testId ?? "channel-sort-trigger"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={ariaLabel}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => onSortModeChange(value as ChannelSortMode)}
|
||||
value={sortMode}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
ArrowUpDown,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Clipboard,
|
||||
Copy,
|
||||
GripVertical,
|
||||
LogOut,
|
||||
EllipsisVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Star,
|
||||
StarOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection";
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
|
||||
import { deferMenuAction } from "@/features/sidebar/ui/sidebarMenuHelpers";
|
||||
import {
|
||||
DraggableChannelRow,
|
||||
DroppableSectionBody,
|
||||
@@ -64,262 +67,264 @@ const SECTION_LABEL_CHEVRON_CLASS =
|
||||
const SECTION_LABEL_CHEVRON_ICON_CLASS =
|
||||
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
|
||||
|
||||
function MoveToSectionSubmenu({
|
||||
channelId,
|
||||
sections,
|
||||
assignments,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
}: {
|
||||
channelId: string;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
onAssignChannel: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel: (channelId: string) => void;
|
||||
onCreateSectionForChannel: (channelId: string) => void;
|
||||
}) {
|
||||
const currentSectionId = assignments[channelId];
|
||||
const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [
|
||||
{ value: "recent", label: "Recent" },
|
||||
{ value: "alpha", label: "A–Z" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A single always-visible "+" quick action shown at the right edge of a
|
||||
* section header, to the left of the ⋮ menu. Used for the most common
|
||||
* per-section create action (New channel, New message) while every other
|
||||
* action stays folded into {@link SectionActionsMenu}.
|
||||
*/
|
||||
export function SectionQuickAction({
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
icon: Icon = Plus,
|
||||
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
icon?: typeof Plus;
|
||||
visibilityClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Move to section</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{sections.map((section) => (
|
||||
<ContextMenuItem
|
||||
key={section.id}
|
||||
onClick={() => onAssignChannel(channelId, section.id)}
|
||||
>
|
||||
{currentSectionId === section.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : section.icon ? (
|
||||
<StatusEmoji className="h-4 w-4" value={section.icon} />
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
{section.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{sections.length > 0 ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem onClick={() => onCreateSectionForChannel(channelId)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New section...
|
||||
</ContextMenuItem>
|
||||
{currentSectionId ? (
|
||||
<ContextMenuItem onClick={() => onUnassignChannel(channelId)}>
|
||||
Remove from section
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
|
||||
data-testid={testId}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelContextMenuItems({
|
||||
channel,
|
||||
/**
|
||||
* The single "more actions" menu shown at the right edge of every sidebar
|
||||
* section header (Starred, Channels, Forums, Direct messages, and each custom
|
||||
* section). Items render only when their handler is provided; a Sort radio
|
||||
* group is appended whenever a sort preference is supplied.
|
||||
*/
|
||||
export function SectionActionsMenu({
|
||||
sectionLabel,
|
||||
testId,
|
||||
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
|
||||
hasUnread,
|
||||
isMuted,
|
||||
isStarred,
|
||||
sections,
|
||||
assignments,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onMuteChannel,
|
||||
onUnmuteChannel,
|
||||
onStarChannel,
|
||||
onUnstarChannel,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
onLeaveChannel,
|
||||
}: {
|
||||
channel: Channel;
|
||||
hasUnread: boolean;
|
||||
isMuted?: boolean;
|
||||
isStarred?: boolean;
|
||||
sections?: ChannelSection[];
|
||||
assignments?: Record<string, string>;
|
||||
onMarkChannelRead?: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread?: (channelId: string) => void;
|
||||
onMuteChannel?: (channelId: string) => void;
|
||||
onUnmuteChannel?: (channelId: string) => void;
|
||||
onStarChannel?: (channelId: string) => void;
|
||||
onUnstarChannel?: (channelId: string) => void;
|
||||
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
|
||||
? Boolean(onMarkChannelRead)
|
||||
: Boolean(onMarkChannelUnread);
|
||||
return (
|
||||
<>
|
||||
{showStar ? (
|
||||
isStarred ? (
|
||||
<ContextMenuItem onClick={() => onUnstarChannel?.(channel.id)}>
|
||||
<StarOff className="h-4 w-4" />
|
||||
Unstar channel
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem onClick={() => onStarChannel?.(channel.id)}>
|
||||
<Star className="h-4 w-4" />
|
||||
Star channel
|
||||
</ContextMenuItem>
|
||||
)
|
||||
) : null}
|
||||
{showStar && showReadToggle ? <ContextMenuSeparator /> : null}
|
||||
{hasUnread && onMarkChannelRead ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Mark as read
|
||||
</ContextMenuItem>
|
||||
) : !hasUnread && onMarkChannelUnread ? (
|
||||
<ContextMenuItem onClick={() => onMarkChannelUnread(channel.id)}>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Mark unread
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onMuteChannel && onUnmuteChannel ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
{isMuted ? (
|
||||
<ContextMenuItem onClick={() => onUnmuteChannel(channel.id)}>
|
||||
<Bell className="h-4 w-4" />
|
||||
Unmute channel
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem onClick={() => onMuteChannel(channel.id)}>
|
||||
<BellOff className="h-4 w-4" />
|
||||
Mute channel
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{sections &&
|
||||
assignments &&
|
||||
onAssignChannel &&
|
||||
onUnassignChannel &&
|
||||
onCreateSectionForChannel ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<MoveToSectionSubmenu
|
||||
channelId={channel.id}
|
||||
sections={sections}
|
||||
assignments={assignments}
|
||||
onAssignChannel={onAssignChannel}
|
||||
onUnassignChannel={onUnassignChannel}
|
||||
onCreateSectionForChannel={onCreateSectionForChannel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
copyTextToClipboard(channel.name, "Channel name copied to clipboard")
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy channel name
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
copyTextToClipboard(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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeaderActions({
|
||||
browseAriaLabel,
|
||||
createAriaLabel,
|
||||
hasUnread,
|
||||
leadingAction,
|
||||
onBrowseClick,
|
||||
onCreateClick,
|
||||
onMarkAllRead,
|
||||
onBrowse,
|
||||
browseLabel,
|
||||
onCreate,
|
||||
createLabel,
|
||||
onNewMessage,
|
||||
newMessageLabel,
|
||||
onRenameSection,
|
||||
onMoveSectionUp,
|
||||
onMoveSectionDown,
|
||||
onDeleteSection,
|
||||
isFirstSection,
|
||||
isLastSection,
|
||||
sortMode,
|
||||
onSortModeChange,
|
||||
}: {
|
||||
browseAriaLabel?: string;
|
||||
createAriaLabel: string;
|
||||
sectionLabel: string;
|
||||
testId?: string;
|
||||
visibilityClassName?: string;
|
||||
hasUnread?: boolean;
|
||||
leadingAction?: React.ReactNode;
|
||||
onBrowseClick?: () => void;
|
||||
onCreateClick?: () => void;
|
||||
onMarkAllRead?: () => void;
|
||||
onBrowse?: () => void;
|
||||
browseLabel?: string;
|
||||
onCreate?: () => void;
|
||||
createLabel?: string;
|
||||
onNewMessage?: () => void;
|
||||
newMessageLabel?: string;
|
||||
onRenameSection?: () => void;
|
||||
onMoveSectionUp?: () => void;
|
||||
onMoveSectionDown?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
isFirstSection?: boolean;
|
||||
isLastSection?: boolean;
|
||||
sortMode?: ChannelSortMode;
|
||||
onSortModeChange?: (mode: ChannelSortMode) => void;
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const showSectionManagement = Boolean(onRenameSection || onDeleteSection);
|
||||
const showSort = Boolean(sortMode && onSortModeChange);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`More actions for ${sectionLabel}`}
|
||||
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
|
||||
data-testid={testId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
<EllipsisVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
triggerRef.current?.blur();
|
||||
}}
|
||||
>
|
||||
{hasUnread && onMarkAllRead ? (
|
||||
<DropdownMenuItem onSelect={() => deferMenuAction(onMarkAllRead)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
<span>Mark all as read</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onNewMessage ? (
|
||||
<DropdownMenuItem onSelect={() => deferMenuAction(onNewMessage)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{newMessageLabel ?? "New message"}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onBrowse ? (
|
||||
<DropdownMenuItem onSelect={() => deferMenuAction(onBrowse)}>
|
||||
<HashSearch className="h-4 w-4" />
|
||||
<span>{browseLabel ?? "Browse channels"}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCreate ? (
|
||||
<DropdownMenuItem onSelect={() => deferMenuAction(onCreate)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{createLabel ?? "Create channel"}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showSectionManagement ? (
|
||||
<>
|
||||
{onRenameSection ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => deferMenuAction(onRenameSection)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span>Rename section</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMoveSectionUp ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isFirstSection}
|
||||
onSelect={() => deferMenuAction(onMoveSectionUp)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<span>Move up</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMoveSectionDown ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isLastSection}
|
||||
onSelect={() => deferMenuAction(onMoveSectionDown)}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<span>Move down</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showSort ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<span>Sort</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) =>
|
||||
onSortModeChange?.(value as ChannelSortMode)
|
||||
}
|
||||
value={sortMode}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
) : null}
|
||||
{onDeleteSection ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => deferMenuAction(onDeleteSection)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Delete section</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelSectionHeader({
|
||||
contentId,
|
||||
isCollapsed,
|
||||
onToggleCollapsed,
|
||||
title,
|
||||
actions,
|
||||
}: {
|
||||
contentId: string;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
title: string;
|
||||
actions: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
|
||||
{leadingAction}
|
||||
{hasUnread && onMarkAllRead ? (
|
||||
<div className="relative">
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
aria-label="Mark all as read"
|
||||
className={cn(
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read"
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={SECTION_LABEL_BUTTON_CLASS}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_ICON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{onBrowseClick ? (
|
||||
<button
|
||||
aria-label={browseAriaLabel}
|
||||
className={cn(
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
onClick={onBrowseClick}
|
||||
title={browseAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<HashSearch className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{onCreateClick ? (
|
||||
<button
|
||||
aria-label={createAriaLabel}
|
||||
className={cn(
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
onClick={onCreateClick}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</SidebarGroupLabel>
|
||||
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelGroupSection({
|
||||
browseAriaLabel,
|
||||
createAriaLabel,
|
||||
browseLabel,
|
||||
createLabel,
|
||||
draggable,
|
||||
groupClassName,
|
||||
hasUnread,
|
||||
@@ -327,16 +332,19 @@ export function ChannelGroupSection({
|
||||
isActiveChannel,
|
||||
activeWorkingByChannelId,
|
||||
items,
|
||||
leadingHeaderAction,
|
||||
listTestId,
|
||||
onBrowseClick,
|
||||
onCreateClick,
|
||||
showQuickCreate,
|
||||
onMarkAllRead,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onSelectChannel,
|
||||
onToggleCollapsed,
|
||||
selectedChannelId,
|
||||
sortMode,
|
||||
onSortModeChange,
|
||||
actionsTestId,
|
||||
title,
|
||||
unreadChannelCounts,
|
||||
unreadChannelIds,
|
||||
@@ -353,18 +361,18 @@ export function ChannelGroupSection({
|
||||
onUnstarChannel,
|
||||
onLeaveChannel,
|
||||
}: {
|
||||
browseAriaLabel?: string;
|
||||
createAriaLabel: string;
|
||||
browseLabel?: string;
|
||||
createLabel?: string;
|
||||
draggable?: boolean;
|
||||
groupClassName?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
|
||||
items: Channel[];
|
||||
leadingHeaderAction?: React.ReactNode;
|
||||
listTestId: string;
|
||||
onBrowseClick?: () => void;
|
||||
onCreateClick?: () => void;
|
||||
showQuickCreate?: boolean;
|
||||
onMarkChannelRead: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
@@ -373,6 +381,9 @@ export function ChannelGroupSection({
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
selectedChannelId: string | null;
|
||||
sortMode?: ChannelSortMode;
|
||||
onSortModeChange?: (mode: ChannelSortMode) => void;
|
||||
actionsTestId?: string;
|
||||
title: string;
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
@@ -462,36 +473,37 @@ export function ChannelGroupSection({
|
||||
<SidebarGroup
|
||||
className={cn("group/sidebar-section select-none", groupClassName)}
|
||||
>
|
||||
<div className="relative">
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={SECTION_LABEL_BUTTON_CLASS}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_ICON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
<ChannelSectionHeader
|
||||
contentId={contentId}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
title={title}
|
||||
actions={
|
||||
<>
|
||||
{showQuickCreate && onCreateClick ? (
|
||||
<SectionQuickAction
|
||||
label={createLabel ?? "Create channel"}
|
||||
onClick={onCreateClick}
|
||||
testId={
|
||||
actionsTestId ? `${actionsTestId}-quick-create` : undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<SectionHeaderActions
|
||||
browseAriaLabel={browseAriaLabel}
|
||||
createAriaLabel={createAriaLabel}
|
||||
hasUnread={hasUnread}
|
||||
leadingAction={leadingHeaderAction}
|
||||
onBrowseClick={onBrowseClick}
|
||||
onCreateClick={onCreateClick}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SectionActionsMenu
|
||||
sectionLabel={title}
|
||||
testId={actionsTestId}
|
||||
hasUnread={hasUnread}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
onBrowse={onBrowseClick}
|
||||
browseLabel={browseLabel}
|
||||
onCreate={onCreateClick}
|
||||
createLabel={createLabel}
|
||||
sortMode={sortMode}
|
||||
onSortModeChange={onSortModeChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!isCollapsed ? (
|
||||
<SidebarGroupContent id={contentId}>{channelList}</SidebarGroupContent>
|
||||
) : null}
|
||||
@@ -519,7 +531,8 @@ export function CustomChannelSection({
|
||||
assignments,
|
||||
isFirst,
|
||||
isLast,
|
||||
leadingHeaderAction,
|
||||
sortMode,
|
||||
onSortModeChange,
|
||||
onToggleCollapsed,
|
||||
onSelectChannel,
|
||||
onMarkChannelRead,
|
||||
@@ -553,7 +566,8 @@ export function CustomChannelSection({
|
||||
assignments: Record<string, string>;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
leadingHeaderAction?: React.ReactNode;
|
||||
sortMode?: ChannelSortMode;
|
||||
onSortModeChange?: (mode: ChannelSortMode) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onMarkChannelRead: (
|
||||
@@ -603,13 +617,6 @@ export function CustomChannelSection({
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-sidebar-foreground/30",
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{section.icon ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -628,58 +635,34 @@ export function CustomChannelSection({
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
className={SECTION_LABEL_CHEVRON_CLASS}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_ICON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
>
|
||||
{leadingHeaderAction}
|
||||
{hasUnread ? (
|
||||
<button
|
||||
aria-label="Mark all as read"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkSectionRead();
|
||||
}}
|
||||
title="Mark all as read"
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Rename section"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRenameSection();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete section"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSection();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<SectionActionsMenu
|
||||
sectionLabel={section.name}
|
||||
testId={`section-actions-${section.id}`}
|
||||
hasUnread={hasUnread}
|
||||
onMarkAllRead={onMarkSectionRead}
|
||||
onRenameSection={onRenameSection}
|
||||
onMoveSectionUp={onMoveSectionUp}
|
||||
onMoveSectionDown={onMoveSectionDown}
|
||||
onDeleteSection={onDeleteSection}
|
||||
isFirstSection={isFirst}
|
||||
isLastSection={isLast}
|
||||
sortMode={sortMode}
|
||||
onSortModeChange={onSortModeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
||||
|
||||
35
desktop/src/features/sidebar/ui/sidebarMenuHelpers.tsx
Normal file
35
desktop/src/features/sidebar/ui/sidebarMenuHelpers.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type * as React from "react";
|
||||
|
||||
const SIDEBAR_CONTEXT_ICON_SLOT_CLASS =
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center";
|
||||
|
||||
/**
|
||||
* Run a menu action on the next tick. Radix closes the menu on select and
|
||||
* restores focus; deferring the action avoids opening a modal dialog while
|
||||
* the menu is still tearing down (which can leave `pointer-events: none`
|
||||
* stuck on <body> and freeze the app).
|
||||
*/
|
||||
export function deferMenuAction(action: () => void) {
|
||||
globalThis.setTimeout(action, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-width leading slot for context-menu rows so that labels stay
|
||||
* left-aligned whether or not a given row has an icon. Pass no children for
|
||||
* an intentionally blank (but still aligned) slot.
|
||||
*/
|
||||
export function ContextMenuIconSlot({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={SIDEBAR_CONTEXT_ICON_SLOT_CLASS}
|
||||
data-sidebar-context-icon-slot
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -66,11 +66,13 @@ test.describe("per-group channel sort", () => {
|
||||
const streamList = page.getByTestId("stream-list");
|
||||
await expect(streamList).toBeVisible();
|
||||
await page.getByText("Channels", { exact: true }).hover();
|
||||
const trigger = page.getByTestId("channel-sort-trigger-channels");
|
||||
const trigger = page.getByTestId("section-actions-channels");
|
||||
await expect(trigger).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/01-channels-sort-ingress.png` });
|
||||
await trigger.click();
|
||||
// Sort is now a submenu flyout — open it before the radio items render.
|
||||
await page.getByRole("menuitem", { name: "Sort" }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { name: "Recent" }),
|
||||
).toBeVisible();
|
||||
@@ -153,9 +155,11 @@ test.describe("per-group channel sort", () => {
|
||||
const dmList = page.getByTestId("dm-list");
|
||||
await expect(dmList).toBeVisible();
|
||||
await page.getByText("Direct messages", { exact: true }).hover();
|
||||
const trigger = page.getByTestId("channel-sort-trigger-dms");
|
||||
const trigger = page.getByTestId("section-actions-dms");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
// Sort is now a submenu flyout — open it before the radio items render.
|
||||
await page.getByRole("menuitem", { name: "Sort" }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { name: "A–Z" }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
TEST_IDENTITIES,
|
||||
installMockBridge,
|
||||
openChannelBrowser,
|
||||
openCreateChannelDialog,
|
||||
openNewDirectMessageDialog,
|
||||
} from "../helpers/bridge";
|
||||
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
@@ -430,7 +432,7 @@ test("shows presence in sidebar, DM header, and member list", async ({
|
||||
test("start a new direct message from the sidebar", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("new-dm-trigger").click();
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("new-dm-search").fill("charlie");
|
||||
@@ -464,7 +466,7 @@ test("start a new direct message from the sidebar", async ({ page }) => {
|
||||
|
||||
await expect(page.getByTestId("dm-list")).toContainText("charlie");
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
|
||||
await expect(page.getByTestId("new-dm-trigger")).not.toBeFocused();
|
||||
await expect(page.getByTestId("section-actions-dms")).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("keeps direct message row add buttons hidden while opening", async ({
|
||||
@@ -480,7 +482,7 @@ test("keeps direct message row add buttons hidden while opening", async ({
|
||||
testWindow.__BUZZ_E2E__.mock.openDmDelayMs = 1_000;
|
||||
});
|
||||
|
||||
await page.getByTestId("new-dm-trigger").click();
|
||||
await openNewDirectMessageDialog(page);
|
||||
await page.getByTestId("new-dm-search").fill("charlie");
|
||||
await page
|
||||
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
|
||||
@@ -507,7 +509,7 @@ test("shows capped participant stack in group direct message header", async ({
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("new-dm-trigger").click();
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
|
||||
for (const identity of [
|
||||
@@ -591,7 +593,7 @@ test("create stream with name and description", async ({ page }) => {
|
||||
const channelName = `my-new-stream-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
@@ -608,7 +610,7 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
|
||||
const channelName = `ephemeral-stream-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
@@ -650,7 +652,7 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
|
||||
await page.goto("/");
|
||||
|
||||
for (const channelName of [firstChannelName, secondChannelName]) {
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
@@ -756,7 +758,7 @@ test("create stream with special characters", async ({ page }) => {
|
||||
const channelName = `dev ops-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
@@ -845,7 +847,7 @@ test("short channel with messages shows intro actions on open", async ({
|
||||
const message = "Only message in a short channel";
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -881,7 +883,7 @@ test("scrollable channel with recent messages hides intro actions until top", as
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -1875,7 +1877,7 @@ test("new DM picker pages people search beyond the first 50 results", async ({
|
||||
await installMockBridge(page, { searchProfiles });
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("new-dm-trigger").click();
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
await page.getByTestId("new-dm-search").fill("Alex");
|
||||
|
||||
@@ -2141,7 +2143,7 @@ test("removing a multi-channel managed bot preserves its record after removal fr
|
||||
await page.goto("/");
|
||||
const agentPubkey = await addGenericAgent(page, "general", agentName);
|
||||
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(secondChannelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
@@ -2325,7 +2327,7 @@ test("manage channel can delete an owned stream", async ({ page }) => {
|
||||
const channelName = `delete-me-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -2346,7 +2348,7 @@ test("canceling channel deletion keeps the owned stream", async ({ page }) => {
|
||||
const channelName = `keep-me-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import {
|
||||
installMockBridge,
|
||||
openNewDirectMessageDialog,
|
||||
} from "../helpers/bridge";
|
||||
|
||||
// Guards the NIP-IA discovery-suppression contract (Dawn's table v2):
|
||||
// - Members sidebar: archived members fold under "Archived (N)", not in the
|
||||
@@ -89,7 +92,7 @@ test.describe("NIP-IA hide archived from discovery", () => {
|
||||
}) => {
|
||||
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("new-dm-trigger").click();
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("new-dm-search").fill("alice");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test, type Browser } from "@playwright/test";
|
||||
import {
|
||||
installRelayBridge,
|
||||
openChannelBrowser,
|
||||
openCreateChannelDialog,
|
||||
TEST_IDENTITIES,
|
||||
} from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
@@ -16,7 +17,7 @@ async function createStream(
|
||||
channelName: string,
|
||||
description?: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
if (description !== undefined) {
|
||||
await page.getByTestId("create-channel-description").fill(description);
|
||||
@@ -182,7 +183,7 @@ test("create channel and verify in sidebar", async ({ page }) => {
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
|
||||
@@ -206,7 +207,7 @@ test("two users see the same channel", async ({
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageOne.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(pageOne);
|
||||
await pageOne.getByTestId("create-channel-name").fill(channelName);
|
||||
await pageOne.getByTestId("create-channel-submit").click();
|
||||
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
|
||||
@@ -428,12 +429,12 @@ test("multiple channels independent", async ({ page }) => {
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelA);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
|
||||
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelB);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge";
|
||||
|
||||
const DEFAULT_AGENT_ACTIVITY_PUBKEY =
|
||||
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36";
|
||||
@@ -100,7 +100,7 @@ test("creates a new mocked stream", async ({ page }) => {
|
||||
const channelName = `release-notes-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
import {
|
||||
installRelayBridge,
|
||||
openChannelBrowser,
|
||||
openCreateChannelDialog,
|
||||
TEST_IDENTITIES,
|
||||
} from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
@@ -59,7 +60,7 @@ async function createAndJoinSharedStream(
|
||||
memberPage: Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await ownerPage.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(ownerPage);
|
||||
await ownerPage.getByTestId("create-channel-name").fill(channelName);
|
||||
await ownerPage.getByTestId("create-channel-submit").click();
|
||||
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
|
||||
@@ -248,7 +249,7 @@ test("creates a relay-backed stream", async ({ page }) => {
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await openCreateChannelDialog(page);
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
|
||||
@@ -559,3 +559,22 @@ export async function openChannelBrowser(page: Page) {
|
||||
);
|
||||
}, isMacBrowser);
|
||||
}
|
||||
|
||||
// Section header actions (create channel, new DM, mark all read, sort) now
|
||||
// live inside a per-section "more actions" (⋮) menu instead of standalone
|
||||
// header icon buttons. These helpers open that menu and pick an item.
|
||||
async function openSectionMenu(page: Page, actionsTestId: string) {
|
||||
const trigger = page.getByTestId(actionsTestId);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function openCreateChannelDialog(page: Page) {
|
||||
await openSectionMenu(page, "section-actions-channels");
|
||||
await page.getByRole("menuitem", { name: "New channel" }).click();
|
||||
}
|
||||
|
||||
export async function openNewDirectMessageDialog(page: Page) {
|
||||
await openSectionMenu(page, "section-actions-dms");
|
||||
await page.getByRole("menuitem", { name: "New message" }).click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user