Skip to content
3 changes: 3 additions & 0 deletions src/bundled/channels/ChannelSidebarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
onSelect,
onNewSession,
onOpenThread,
onHideDm,
}: {
channel: ChannelSummary;
session: RelaySession;
Expand All @@ -43,6 +44,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
onSelect: (id: string) => void;
onNewSession: (id: string) => void;
onOpenThread: (channelId: string, rootId: string) => void;
onHideDm?: (id: string) => void;
}) {
const Icon =
channel.channelType === "dm"
Expand Down Expand Up @@ -97,6 +99,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
onPrepare={(id) => session.channels.prepare?.(id)}
onSelect={onSelect}
onNewSession={onNewSession}
{...(onHideDm ? { onHideDm } : {})}
/>
);
});
22 changes: 21 additions & 1 deletion src/bundled/channels/ChannelSidebarRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
background: transparent;
transition:
color var(--duration-state) var(--easing-state),
background-color var(--duration-state) var(--easing-state);
background-color 90ms cubic-bezier(0.23, 1, 0.32, 1);
}
.row:hover:not([data-selected]) {
background: var(--affordance-subtle);
Expand All @@ -27,12 +27,32 @@
opacity: 0;
pointer-events: none;
flex-shrink: 0;
transition: opacity 90ms cubic-bezier(0.23, 1, 0.32, 1);
}
.row .remove {
position: absolute;
inset-block: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-end;
width: calc(var(--size-row) + var(--space-control-inset));
border-radius: inherit;
background-color: inherit;
}
.row:is(:hover, :has(:focus-visible)) .more,
.row .more:has([data-popup-open]) {
opacity: 1;
pointer-events: auto;
}
.row .remove [data-icon-variant] {
color: var(--text-subtle);
transition-duration: 90ms;
}
.row .remove [data-icon-variant]:is(:hover, :focus-visible) {
color: var(--text-standard);
}
.iconSpace {
width: 17px;
flex: 0 0 17px;
Expand Down
64 changes: 64 additions & 0 deletions src/bundled/channels/ChannelSidebarRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,70 @@ it("hides session actions when the Sessions plugin is unavailable", () => {
screen.queryByRole("button", { name: "More options for Engineering" }),
).not.toBeInTheDocument();
});
it("offers a separate hide action only for DM rows", async () => {
const onHideDm = vi.fn();
const onSelect = vi.fn();
render(
<ChannelSidebarRow
channel={{ id: "dm", name: "Alice", channelType: "dm" }}
collapsed={false}
onToggle={() => {}}
icon={<svg />}
sessions={[]}
sessionsEnabled={false}
draft={false}
draftSelected={false}
onSelect={onSelect}
onPrepare={() => {}}
onNewSession={() => {}}
onHideDm={onHideDm}
/>,
);
const remove = screen.getByRole("button", {
name: "Remove Alice from DMs",
});
expect(remove).not.toHaveAttribute("title");
await userEvent.setup().click(remove);
expect(onHideDm).toHaveBeenCalledWith("dm");
expect(onSelect).not.toHaveBeenCalled();
});
it("keeps focus in the sidebar when removing its last visible DM", async () => {
function OnlyDm() {
const [visible, setVisible] = useState(true);
return (
<div>
<details open>
<summary>Channels</summary>
</details>
{visible && (
<details open>
<summary>DMs</summary>
<ChannelSidebarRow
channel={{ id: "dm", name: "Alice", channelType: "dm" }}
collapsed={false}
onToggle={() => {}}
icon={<svg />}
sessions={[]}
sessionsEnabled={false}
draft={false}
draftSelected={false}
onSelect={() => {}}
onPrepare={() => {}}
onNewSession={() => {}}
onHideDm={() => setVisible(false)}
/>
</details>
)}
</div>
);
}
render(<OnlyDm />);
const remove = screen.getByRole("button", { name: "Remove Alice from DMs" });
remove.focus();
await userEvent.setup().keyboard("{Enter}");
expect(screen.queryByText("DMs")).not.toBeInTheDocument();
expect(screen.getByText("Channels")).toHaveFocus();
});
it("opens saved child sessions and retained drafts without a channel icon", async () => {
const user = userEvent.setup();
const callbacks = mount();
Expand Down
32 changes: 32 additions & 0 deletions src/bundled/channels/ChannelSidebarRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CaretDownIcon,
CaretRightIcon,
DotsThreeVerticalIcon,
XIcon,
} from "../../shared/design-system/icons/index";
import { useId, useRef, type ReactElement, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
Expand All @@ -27,6 +28,7 @@ export function ChannelSidebarRow({
onSelect,
onPrepare,
onNewSession,
onHideDm,
}: {
channel: ChannelSummary;
icon: ReactNode;
Expand All @@ -43,6 +45,7 @@ export function ChannelSidebarRow({
onSelect: (id: string) => void;
onPrepare: (id: string) => void;
onNewSession: (id: string) => void;
onHideDm?: (id: string) => void;
}) {
const starting = useRef(false);
const childrenId = useId();
Expand Down Expand Up @@ -162,6 +165,35 @@ export function ChannelSidebarRow({
</Menu.Portal>
</Menu.Root>
)}
{channel.channelType === "dm" && onHideDm && (
<span className={`${styles.more} ${styles.remove}`}>
<IconButton
type="button"
size="compact"
shape="round"
aria-label={`Remove ${channel.name} from DMs`}
onClick={(event) => {
const section = event.currentTarget.closest("details");
const rows = [
...(section?.querySelectorAll<HTMLElement>(
"button[data-channel-id]",
) ?? []),
];
const index = rows.findIndex(
(row) => row.dataset.channelId === channel.id,
);
const otherSection = [
...(section?.parentElement?.querySelectorAll<HTMLElement>(
"details > summary",
) ?? []),
].find((summary) => summary.parentElement !== section);
(rows[index + 1] ?? rows[index - 1] ?? otherSection)?.focus();
onHideDm(channel.id);
}}
icon={<XIcon size={15} aria-hidden="true" />}
/>
</span>
)}
</div>
<div className={styles.sessions} id={childrenId} hidden={collapsed}>
{draft && (
Expand Down
10 changes: 9 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { readView, writeView } from "../../shared/view-state";
import { useChannelLabels } from "./useChannelLabels";
import { useSidebarPreferences } from "./useSidebarPreferences";
import { isChannelSectionKey, sidebarSections } from "./sidebar-sections";
import { useHiddenDms } from "./useHiddenDms";
import { SidebarSectionIcon } from "./SidebarSectionIcon";
import {
CreateChannelDialog,
Expand Down Expand Up @@ -199,6 +200,7 @@ function ChannelWorkspace({
.map((entry) => entry.channelId),
]);
const preferences = useSidebarPreferences(queries.sidebarPreferences);
const hiddenDms = useHiddenDms(scope, queries, list);
useEffect(() => {
void queries.emoji.ensure();
}, [queries]);
Expand Down Expand Up @@ -798,6 +800,11 @@ function ChannelWorkspace({
const drawer = useChannelPanels(panels, drawerContext, () =>
setSettings(undefined),
);
const sections = sidebarSections(
channels,
preferences.data,
hiddenDms.hiddenIds,
);
return (
<div
className={`${styles.board} ${showingSettings || panel || showingThread || companion ? styles.withPanel : ""}`}
Expand All @@ -810,7 +817,7 @@ function ChannelWorkspace({
<Panel as="aside" aria-label="Channel sidebar">
<div className={styles.sidebar}>
<SidebarUnread listRef={sidebar.list}>
{sidebarSections(channels, preferences.data).map((section) => {
{sections.map((section) => {
const showsCreateChannel = isChannelSectionKey(section.key);
return (
<details
Expand Down Expand Up @@ -889,6 +896,7 @@ function ChannelWorkspace({
onSelect={select}
onNewSession={startSession}
onOpenThread={openActivityThread}
onHideDm={hiddenDms.hide}
/>
);
})}
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/channels/sidebar-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ it("intersects groups/stars with active authorized streams, keeping forums and D
["dms", ["dm", "group-dm"]],
]);
expect(sidebarSections(roster, preferences)[1]?.icon).toBe(":party:");
expect(
sidebarSections(roster, preferences, new Set(["dm", "group-dm"])).some(
(section) => section.key === "dms",
),
).toBe(false);
expect(
project(roster.filter((channel) => channel.id !== "star")),
).not.toContainEqual(["starred", ["star"]]);
Expand Down
5 changes: 4 additions & 1 deletion src/bundled/channels/sidebar-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function isChannelSectionKey(key: string) {
export function sidebarSections(
channels: readonly ChannelSummary[],
preferences?: SidebarPreferences,
hiddenDms: ReadonlySet<string> = new Set(),
) {
const active = channels.filter(
(channel) =>
Expand Down Expand Up @@ -59,7 +60,9 @@ export function sidebarSections(
key: "dms",
title: "DMs",
icon: undefined,
rows: active.filter((channel) => channel.channelType === "dm"),
rows: active.filter(
(channel) => channel.channelType === "dm" && !hiddenDms.has(channel.id),
),
},
].filter((section) => section.key === "channels" || section.rows.length);
}
Loading
Loading