Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,8 @@ export type ChannelActionType =
| "delete"
| "star"
| "unstar"
| "hide"
| "unhide"
| "edit_context_open"
| "new_task_open"
| "new_task_suggestion"
Expand Down Expand Up @@ -966,6 +968,7 @@ export interface ChannelsSpaceViewedProperties {
/** Total channels visible when the space mounts. */
channel_count: number;
starred_count: number;
hidden_count: number;
}

// Subscription / billing events
Expand Down
101 changes: 88 additions & 13 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {
CaretDownIcon,
CaretRightIcon,
ChartBarIcon,
DotsThreeIcon,
EyeIcon,
EyeSlashIcon,
FileTextIcon,
LinkIcon,
LockSimpleIcon,
Expand Down Expand Up @@ -40,6 +44,10 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal";
import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics";
import {
useChannelHides,
useChannelHideToggle,
} from "@posthog/ui/features/canvas/hooks/useChannelHides";
import {
useChannelStars,
useChannelStarToggle,
Expand Down Expand Up @@ -76,9 +84,10 @@ type ChannelActionItem = {
separatorBefore?: boolean;
};

// The channel actions (star, copy link, rename, delete) plus the rename-modal
// state they drive. Single source of truth so the dropdown and context menus
// stay in lockstep — add an action here and both surfaces pick it up.
// The channel actions (star, hide, copy link, rename, delete) plus the
// rename-modal state they drive. Single source of truth so the dropdown and
// context menus stay in lockstep — add an action here and both surfaces pick
// it up.
function useChannelActions(channel: Channel): {
actions: ChannelActionItem[];
renameOpen: boolean;
Expand All @@ -96,6 +105,8 @@ function useChannelActions(channel: Channel): {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const { deleteChannel, isDeleting } = useChannelMutations();
const { isStarred, toggleStar, removeStar } = useChannelStarToggle(channel);
const { isHidden, toggleHidden, removeHidden } =
useChannelHideToggle(channel);

// Runs the actual delete once confirmed. Returns whether it succeeded so the
// dialog can stay open (and show the toast) on failure.
Expand All @@ -120,6 +131,7 @@ function useChannelActions(channel: Channel): {

await deleteChannel(channel.id);
removeStar();
removeHidden();
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "delete",
surface: "sidebar",
Expand Down Expand Up @@ -159,6 +171,19 @@ function useChannelActions(channel: Channel): {
toggleStar();
},
},
{
key: "hide",
label: isHidden ? "Unhide context" : "Hide context",
icon: isHidden ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />,
onSelect: () => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: isHidden ? "unhide" : "hide",
surface: "sidebar",
channel_id: channel.id,
});
toggleHidden();
},
},
{
key: "copy-link",
label: "Copy link",
Expand Down Expand Up @@ -532,30 +557,50 @@ function PersonalChannelRow() {

// The channel list — the Channels space sidebar body. The private "#me"
// channel is pinned at the top; starred channels surface in their own section
// so the ones you use most stay in reach; the rest sit under a "Channels"
// label with the "New" channel button.
// so the ones you use most stay in reach; the rest sit under a "Contexts"
// label with the "New" channel button; channels the user has hidden collapse
// into a "Hidden" group at the bottom.
export function ChannelsList() {
const { channels: allChannels, isLoading } = useChannels();
const { starredRefToShortcutId } = useChannelStars();
const { starredRefToShortcutId, isLoading: starsLoading } = useChannelStars();
const { hiddenRefToShortcutId, isLoading: hidesLoading } = useChannelHides();
const [modalOpen, setModalOpen] = useState(false);
// The "Hidden" group is collapsed by default — hidden channels are ones the
// user chose to get out of the way.
const [hiddenExpanded, setHiddenExpanded] = useState(false);

// The "me" folder renders as the pinned personal row, not a shared channel.
const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME);
const starred = channels.filter((c) => starredRefToShortcutId.has(c.path));
const others = channels.filter((c) => !starredRefToShortcutId.has(c.path));
// Hiding wins over starring: a hidden channel leaves both the starred and
// main lists and only surfaces under the collapsed "Hidden" group.
const hidden = channels.filter((c) => hiddenRefToShortcutId.has(c.path));
const visible = channels.filter((c) => !hiddenRefToShortcutId.has(c.path));
const starred = visible.filter((c) => starredRefToShortcutId.has(c.path));
const others = visible.filter((c) => !starredRefToShortcutId.has(c.path));

// Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load
// (so the counts are accurate). The sidebar stays mounted while navigating
// between channels, so this naturally fires once per entry into the space.
// Fire CHANNELS_SPACE_VIEWED once per space mount, after channels *and* the
// shortcuts (stars/hides) first load — the shortcuts query is independent, so
// gating only on channels would let the one-shot event record stale zero
// starred/hidden counts. The sidebar stays mounted while navigating between
// channels, so this naturally fires once per entry into the space.
const viewedTrackedRef = useRef(false);
useEffect(() => {
if (isLoading || viewedTrackedRef.current) return;
if (isLoading || starsLoading || hidesLoading || viewedTrackedRef.current)
return;
viewedTrackedRef.current = true;
track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, {
channel_count: channels.length,
starred_count: starred.length,
hidden_count: hidden.length,
});
}, [isLoading, channels.length, starred.length]);
}, [
isLoading,
starsLoading,
hidesLoading,
channels.length,
starred.length,
hidden.length,
]);

return (
// One shared provider groups every row tooltip so that once one shows,
Expand Down Expand Up @@ -608,6 +653,36 @@ export function ChannelsList() {
<ChannelSection key={channel.id} channel={channel} />
))}
</div>

{hidden.length > 0 && (
<Box className="mt-3">
<MenuLabel className="uppercase">
<button
type="button"
aria-expanded={hiddenExpanded}
onClick={() => setHiddenExpanded((open) => !open)}
className="flex w-full items-center gap-2"
>
{hiddenExpanded ? (
<CaretDownIcon size={14} className="text-gray-9" />
) : (
<CaretRightIcon size={14} className="text-gray-9" />
)}
<EyeSlashIcon size={14} className="text-gray-9" />
Hidden
<span className="ml-0.5 text-gray-9">{hidden.length}</span>
</button>
</MenuLabel>
</Box>
)}

{hidden.length > 0 && hiddenExpanded && (
<div className="pl-2">
{hidden.map((channel) => (
<ChannelSection key={channel.id} channel={channel} />
))}
</div>
)}
</Flex>

<CreateChannelModal open={modalOpen} onOpenChange={setModalOpen} />
Expand Down
137 changes: 137 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannelHides.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Schemas } from "@posthog/api-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockClient = vi.hoisted(() => ({
getDesktopFileSystemShortcuts: vi.fn(),
createDesktopFileSystemShortcut: vi.fn(),
deleteDesktopFileSystemShortcut: vi.fn(),
}));
vi.mock("@posthog/ui/features/auth/authClient", () => ({
useOptionalAuthenticatedClient: () => mockClient,
}));
vi.mock("@posthog/ui/primitives/toast", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));

import { useChannelHides, useChannelHideToggle } from "./useChannelHides";
import type { Channel } from "./useChannels";

function shortcut(
id: string,
type: string,
ref: string | null,
): Schemas.FileSystemShortcut {
return {
id,
path: ref?.replace(/^\/+/, "") ?? "x",
type,
ref,
created_at: "2026-01-01T00:00:00Z",
};
}

function channel(id: string, name: string, path: string): Channel {
return { id, name, path };
}

let queryClient: QueryClient;
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

describe("useChannelHides", () => {
beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});

it("maps hidden-folder shortcuts by ref, ignoring stars and ref-less rows", async () => {
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
shortcut("s1", "hidden-folder", "/alpha"),
shortcut("s2", "folder", "/beta"), // a star, not a hide
shortcut("s3", "hidden-folder", null), // no ref to link
]);

const { result } = renderHook(() => useChannelHides(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect([...result.current.hiddenRefToShortcutId.entries()]).toEqual([
["/alpha", "s1"],
]);
});

it("hides an unhidden channel via its raw path, updating the cache immediately", async () => {
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([]);

const hides = renderHook(() => useChannelHides(), { wrapper });
await waitFor(() => expect(hides.result.current.isLoading).toBe(false));

const created = shortcut("s1", "hidden-folder", "/alpha");
mockClient.createDesktopFileSystemShortcut.mockResolvedValue(created);
// Hang the refetch so only the optimistic cache write is exercised.
mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
new Promise(() => {}),
);

const toggle = renderHook(
() => useChannelHideToggle(channel("1", "alpha", "/alpha")),
{ wrapper },
);
expect(toggle.result.current.isHidden).toBe(false);

await act(async () => {
toggle.result.current.toggleHidden();
});

expect(mockClient.createDesktopFileSystemShortcut).toHaveBeenCalledWith({
path: "alpha",
type: "hidden-folder",
ref: "/alpha",
});
await waitFor(() =>
expect(hides.result.current.hiddenRefToShortcutId.get("/alpha")).toBe(
"s1",
),
);
});

it("unhides a hidden channel by deleting its shortcut id", async () => {
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
shortcut("s1", "hidden-folder", "/alpha"),
]);

const hides = renderHook(() => useChannelHides(), { wrapper });
await waitFor(() => expect(hides.result.current.isLoading).toBe(false));

mockClient.deleteDesktopFileSystemShortcut.mockResolvedValue(undefined);
mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
new Promise(() => {}),
);

const toggle = renderHook(
() => useChannelHideToggle(channel("1", "alpha", "/alpha")),
{ wrapper },
);
expect(toggle.result.current.isHidden).toBe(true);

await act(async () => {
toggle.result.current.toggleHidden();
});

expect(mockClient.deleteDesktopFileSystemShortcut).toHaveBeenCalledWith(
"s1",
);
await waitFor(() =>
expect(hides.result.current.hiddenRefToShortcutId.has("/alpha")).toBe(
false,
),
);
});
});
Loading
Loading