Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/core/src/inbox/reportMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ export function isInboxDetailPath(pathname: string): boolean {
return INBOX_DETAIL_PATH_RE.test(pathname);
}

/** Which tab a list pathname belongs to; anything unrecognised reads as Pulls. */
export function inboxTabFromPath(pathname: string): InboxTabKey {
if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports";
if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs";
if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed";
return "pulls";
}

/**
* Whether the reviewer-scope control means anything on this tab: Runs is
* unscoped and the Archive is a terminal list, so neither filters by reviewer.
*/
export function inboxScopeApplies(tab: InboxTabKey): boolean {
return tab !== "runs" && tab !== "dismissed";
}

/**
* PR tab membership: Responder shipped a draft PR and it is `ready` for review.
* PRs that have already been merged/closed (`resolved`) or are still running
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,8 @@ export type ChannelActionType =
| "view_activity"
| "open_mention"
| "canvas_mode_toggle"
| "activity_tab_change";
| "activity_tab_change"
| "artifacts_view_change";

export interface ChannelActionProperties {
action_type: ChannelActionType;
Expand All @@ -939,6 +940,7 @@ export interface ChannelActionProperties {
armed?: boolean;
/** For activity_tab_change: the tab landed on. */
tab?: string;
view_mode?: "list" | "grid" | "masonry";
/** Whether the underlying mutation resolved successfully. */
success?: boolean;
}
Expand All @@ -947,6 +949,8 @@ export type DashboardActionType =
| "open"
| "create"
| "delete"
/** The delete was undone inside its undo window, so nothing was removed. */
| "delete_undo"
| "rename"
| "save"
| "fork"
Expand Down
241 changes: 170 additions & 71 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,23 @@ import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMa
import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity";
import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
import {
PageHeader,
PageHeaderActions,
PageHeaderChip,
PageHeaderDescription,
PageHeaderHeading,
PageHeaderTitle,
PageHeaderTitleRow,
} from "@posthog/ui/primitives/PageHeader";
import {
navigateToChannelTask,
navigateToTaskDetail,
} from "@posthog/ui/router/navigationBridge";
import { track } from "@posthog/ui/shell/analytics";
import { Text } from "@radix-ui/themes";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { memo, useCallback, useEffect, useMemo } from "react";
import {
activityReadPayload,
channelIdForName,
Expand Down Expand Up @@ -278,86 +287,176 @@ export function ActivityView() {
() => createChannelIdByName(folderChannels),
[folderChannels],
);
const folderChannelIdFor = (channelName: string | null): string | null =>
channelIdForName(folderIdByName, channelName);
const folderChannelIdFor = useCallback(
(channelName: string | null): string | null =>
channelIdForName(folderIdByName, channelName),
[folderIdByName],
);
useEffect(() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "view_activity",
surface: "activity",
});
}, []);

return (
<div className="h-full overflow-y-auto bg-gray-1">
<div className="mx-auto w-full max-w-[680px] px-4 py-6">
<div className="flex items-start justify-between gap-4">
<div>
<Text size="5" weight="bold" className="block">
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Tasks you're involved in across{" "}
{spacesLayout ? "spaces" : "channels"}.
</Text>
</div>
{unreadCount > 0 && (
<Button
variant="default"
size="sm"
loading={isMarkingRead}
disabled={isMarkingRead}
onClick={markAllRead}
>
<ChecksIcon size={14} />
{markLoadedReadLabel(unreadItems.length, unreadCount)}
</Button>
)}
</div>
<div className="mt-4">
{isLoading && items.length === 0 ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BellIcon size={20} />
</EmptyMedia>
<EmptyTitle>No activity yet</EmptyTitle>
<EmptyDescription>
Tasks you create, get tagged in, or reply to across{" "}
{spacesLayout ? "spaces" : "channels"} land here.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<ActivityRow
key={item.taskId}
item={item}
folderChannelId={folderChannelIdFor(item.channelName)}
onOpen={markRead}
onMarkRead={markRead}
currentUser={currentUser}
/>
))}
{hasNextPage && (
<Button
variant="outline"
className="mt-3 self-center"
loading={isFetchingNextPage}
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
Load more
</Button>
)}
const markAllReadButton = useMemo(
() =>
unreadCount > 0 ? (
<Button
variant="default"
size="sm"
loading={isMarkingRead}
disabled={isMarkingRead}
onClick={markAllRead}
>
<ChecksIcon size={14} />
{markLoadedReadLabel(unreadItems.length, unreadCount)}
</Button>
) : null,
[unreadCount, unreadItems.length, isMarkingRead, markAllRead],
);

const feed = (
<ActivityFeed
items={items}
isLoading={isLoading}
spacesLayout={spacesLayout}
folderChannelIdFor={folderChannelIdFor}
markRead={markRead}
currentUser={currentUser}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
);

// The shared page header ships with the spaces layout; without it the page
// keeps the in-container title it has always had. Delete the legacy branch
// when the layout flag graduates.
if (!spacesLayout) {
return (
<div className="h-full overflow-y-auto bg-gray-1">
<div className="mx-auto w-full max-w-[680px] px-4 py-6">
<div className="flex items-start justify-between gap-4">
<div>
<Text size="5" weight="bold" className="block">
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Tasks you're involved in across{" "}
{spacesLayout ? "spaces" : "channels"}.
</Text>
</div>
)}
{markAllReadButton}
</div>
<div className="mt-4">{feed}</div>
</div>
</div>
);
}

return (
<div className="flex h-full min-h-0 flex-col bg-gray-1">
<PageHeader>
<PageHeaderHeading>
<PageHeaderTitleRow>
<PageHeaderTitle>Activity</PageHeaderTitle>
{unreadCount > 0 && (
<PageHeaderChip icon={<BellIcon size={12} weight="fill" />}>
{unreadCount} unread
</PageHeaderChip>
)}
{markAllReadButton && (
<PageHeaderActions>{markAllReadButton}</PageHeaderActions>
)}
</PageHeaderTitleRow>
<PageHeaderDescription>
Tasks you're involved in across{" "}
{spacesLayout ? "spaces" : "channels"}.
</PageHeaderDescription>
</PageHeaderHeading>
</PageHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[680px] px-4 py-6">{feed}</div>
</div>
</div>
);
}

/**
* The feed body. A memo'd child rather than JSX built in the parent: the parent
* picks between two page shells and returns early, and this way the branch it
* doesn't take costs nothing.
*/
const ActivityFeed = memo(function ActivityFeed({
items,
isLoading,
spacesLayout,
folderChannelIdFor,
markRead,
currentUser,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}: {
items: TaskActivityItem[];
isLoading: boolean;
spacesLayout: boolean;
folderChannelIdFor: (channelName: string | null) => string | null;
markRead: (item: TaskActivityItem) => void;
currentUser?: UserBasic | null;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}) {
if (isLoading && items.length === 0) {
return (
<div className="flex justify-center py-16">
<Spinner />
</div>
);
}

if (items.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BellIcon size={20} />
</EmptyMedia>
<EmptyTitle>No activity yet</EmptyTitle>
<EmptyDescription>
Tasks you create, get tagged in, or reply to across{" "}
{spacesLayout ? "spaces" : "channels"} land here.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

return (
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<ActivityRow
key={item.taskId}
item={item}
folderChannelId={folderChannelIdFor(item.channelName)}
onOpen={markRead}
onMarkRead={markRead}
currentUser={currentUser}
/>
))}
{hasNextPage && (
<Button
variant="outline"
className="mt-3 self-center"
loading={isFetchingNextPage}
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
Load more
</Button>
)}
</div>
);
});
68 changes: 68 additions & 0 deletions packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react";
import {
ToggleGroup,
ToggleGroupItem,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@posthog/quill";
import {
ARTIFACTS_VIEW_MODES,
type ArtifactsViewMode,
useArtifactsViewStore,
} from "@posthog/ui/features/canvas/stores/artifactsViewStore";
import type { ComponentType } from "react";

const OPTIONS: {
mode: ArtifactsViewMode;
label: string;
Icon: ComponentType<{ size?: number; weight?: "bold" }>;
}[] = [
{ mode: "list", label: "List", Icon: ListIcon },
{ mode: "grid", label: "Grid", Icon: SquaresFourIcon },
{ mode: "masonry", label: "Masonry", Icon: Kanban },
];

function isViewMode(value: string | undefined): value is ArtifactsViewMode {
return ARTIFACTS_VIEW_MODES.some((mode) => mode === value);
}

// Layout switcher for the artifacts list. A quill ToggleGroup carries the
// pressed state itself, so there's no hand-rolled active styling here.
export function ArtifactsViewToggle({ channelId }: { channelId?: string }) {
const view = useArtifactsViewStore((s) => s.view);
const setView = useArtifactsViewStore((s) => s.setView);

return (
<TooltipProvider delay={0}>
<ToggleGroup
aria-label="Artifacts view"
value={[view]}
onValueChange={(next: string[]) => {
// Pressing the active item would otherwise clear the group — a view
// is always on, so ignore the empty result.
const mode = next[0];
if (isViewMode(mode)) setView(mode, channelId);
}}
>
{OPTIONS.map(({ mode, label, Icon }) => (
<Tooltip key={mode}>
<TooltipTrigger
render={
<ToggleGroupItem
value={mode}
size="icon"
aria-label={`${label} view`}
>
<Icon size={14} weight="bold" />
</ToggleGroupItem>
}
/>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
</TooltipProvider>
);
}
Loading
Loading