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
101 changes: 101 additions & 0 deletions packages/core/src/canvas/channelUnread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
latestActivityForChannel,
unreadChannelIds,
} from "@posthog/core/canvas/channelUnread";
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
import { describe, expect, it } from "vitest";

function mention(
overrides: Partial<MentionActivityItem> & { createdAt: string },
): MentionActivityItem {
return {
messageId: `m-${overrides.createdAt}`,
taskId: "t1",
taskTitle: "Task",
channelId: "c1",
channelName: "mobile",
author: null,
content: "hey @adam",
...overrides,
};
}

describe("unreadChannelIds", () => {
const cases: {
name: string;
lastSeen: Record<string, string>;
expected: string[];
}[] = [
{
name: "a channel never seen is unread",
lastSeen: {},
expected: ["c1"],
},
{
name: "activity newer than the last visit is unread",
lastSeen: { c1: "2026-07-16T09:00:00.000Z" },
expected: ["c1"],
},
{
name: "activity older than the last visit is read",
lastSeen: { c1: "2026-07-16T11:00:00.000Z" },
expected: [],
},
{
name: "activity exactly at the last visit is read",
lastSeen: { c1: "2026-07-16T10:00:00.000Z" },
expected: [],
},
];
it.each(cases)("$name", ({ lastSeen, expected }) => {
const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })];
expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected);
});

it("compares each channel against its own last visit", () => {
const items = [
mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }),
mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }),
];
const unread = unreadChannelIds(items, {
c1: "2026-07-16T11:00:00.000Z",
c2: "2026-07-16T09:00:00.000Z",
});
expect([...unread]).toEqual(["c2"]);
});

it("uses the newest item in a channel, whatever the order", () => {
const items = [
mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }),
mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }),
];
expect([
...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }),
]).toEqual(["c1"]);
});

it("ignores channel-less mentions", () => {
const items = [
mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }),
];
expect([...unreadChannelIds(items, {})]).toEqual([]);
});
});

describe("latestActivityForChannel", () => {
it("returns the newest timestamp for that channel only", () => {
const items = [
mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }),
mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }),
mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }),
];
expect(latestActivityForChannel(items, "c1")).toBe(
"2026-07-16T12:00:00.000Z",
);
});

it("is undefined for a channel with no activity, or no channel", () => {
expect(latestActivityForChannel([], "c1")).toBeUndefined();
expect(latestActivityForChannel([], undefined)).toBeUndefined();
});
});
66 changes: 66 additions & 0 deletions packages/core/src/canvas/channelUnread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";

/**
* Which channels have activity the viewer hasn't seen — the signal behind the
* sidebar's bold channel names.
*
* "Activity" is currently an @-mention: that's the only cross-channel,
* all-users feed the client has (the mentions index), and it's what
* "notification" means elsewhere in the app. The backend exposes no per-channel
* activity timestamp, so a broader "any new message" signal would mean polling
* every user's full task list — the app's heaviest poll, deliberately retired.
* If that timestamp lands, only `latestActivityByChannel` changes shape; the
* unread comparison and the seen bookkeeping stay as they are.
*
* Keyed by backend channel id rather than name, so renaming a channel doesn't
* silently mark it unread again.
*/

/** Newest activity per channel id. Ignores items with no channel. */
export function latestActivityByChannel(
items: readonly MentionActivityItem[],
): Map<string, string> {
const latest = new Map<string, string>();
for (const item of items) {
if (!item.channelId) continue;
const current = latest.get(item.channelId);
if (!current || item.createdAt > current) {
latest.set(item.channelId, item.createdAt);
}
}
return latest;
}

/**
* Channel ids whose newest activity postdates the viewer's last visit. A
* channel never visited is unread as soon as it has any activity.
*/
export function unreadChannelIds(
items: readonly MentionActivityItem[],
lastSeenByChannel: Readonly<Record<string, string>>,
): Set<string> {
const unread = new Set<string>();
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
const seenAt = lastSeenByChannel[channelId];
if (!seenAt || activityAt > seenAt) unread.add(channelId);
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Bounded Cache Drops Unread Channels

The mentions cache keeps only its newest 300 items, but this loop treats that cache as a complete channel index. After enough mentions arrive, an unread channel whose latest mention was evicted disappears from this set, so its sidebar row incorrectly returns to normal weight.

}
return unread;
}

/**
* The newest activity in one channel, for stamping it seen while it's open.
* Scans for the one channel rather than reusing `latestActivityByChannel`,
* which would build (and throw away) a map of every other channel to answer.
*/
export function latestActivityForChannel(
items: readonly MentionActivityItem[],
channelId: string | undefined,
): string | undefined {
if (!channelId) return undefined;
let latest: string | undefined;
for (const item of items) {
if (item.channelId !== channelId) continue;
if (!latest || item.createdAt > latest) latest = item.createdAt;
}
return latest;
}
4 changes: 4 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HashIcon } from "@phosphor-icons/react";
import { Button, cn } from "@posthog/quill";
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen";
import { Text } from "@radix-ui/themes";
import { useNavigate, useRouterState } from "@tanstack/react-router";

Expand All @@ -17,6 +18,9 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
const channelName = channels.find((c) => c.id === channelId)?.name;
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isHome = pathname === `/website/${channelId}`;
// Every channel surface renders this header, so it is where "the viewer is
// in this channel" is known — and therefore where the channel is marked read.
useMarkChannelSeen(channelName);

return (
<div className="flex min-w-0 items-center gap-2">
Expand Down
75 changes: 66 additions & 9 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
PERSONAL_CHANNEL_NAME,
useTaskChannels,
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels";
import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink";
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
import { toast } from "@posthog/ui/primitives/toast";
Expand Down Expand Up @@ -291,7 +292,14 @@ function ChannelMenu({

// One channel in the list: a "# name" row that navigates to the channel home.
// No expansion — the channel's surfaces live in the in-channel top nav.
function ChannelSection({ channel }: { channel: Channel }) {
function ChannelSection({
channel,
isUnread,
}: {
channel: Channel;
/** Bolds the name: activity here the viewer hasn't seen. */
isUnread?: boolean;
}) {
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const base = `/website/${channel.id}`;
Expand Down Expand Up @@ -340,10 +348,26 @@ function ChannelSection({ channel }: { channel: Channel }) {
}}
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
>
<HashIcon size={14} className="shrink-0 text-gray-9" />
<HashIcon
size={14}
weight={isUnread ? "bold" : undefined}
className={cn(
"shrink-0",
isUnread || isActive
? "text-foreground"
: "text-muted-foreground group-hover/button:text-foreground",
)}
/>
<span
className={cn(
"truncate font-medium text-[13px] text-gray-12 group-hover/chan:pr-8",
"truncate text-[13px] group-hover/chan:pr-8",
// Bold is unread's alone; full contrast is shared with the
// channel you're in. Either way there's no hover brighten
// left to do, so those rows skip it.
isUnread ? "font-bold" : "font-medium",
isUnread || isActive
? "text-foreground"
: "text-muted-foreground group-hover/button:text-foreground",
menuOpen && "pr-8",
)}
>
Expand Down Expand Up @@ -498,6 +522,7 @@ function PersonalChannelRow() {
useTaskChannels();
// The "+" dropdown (New task / New canvas), mirroring a shared channel row.
const [newMenuOpen, setNewMenuOpen] = useState(false);
const isUnread = useIsChannelUnread()(PERSONAL_CHANNEL_NAME);

const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME);
const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id);
Expand Down Expand Up @@ -560,16 +585,33 @@ function PersonalChannelRow() {
onClick={() => void open()}
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
>
<HashIcon size={14} className="shrink-0 text-gray-9" />
<span className="truncate font-medium text-[13px] text-gray-12">
<HashIcon
size={14}
weight={isUnread ? "bold" : undefined}
className={cn(
"shrink-0",
isUnread || isActive
? "text-foreground"
: "text-muted-foreground group-hover/button:text-foreground",
)}
/>
<span
className={cn(
"truncate text-[13px]",
isUnread ? "font-bold" : "font-medium",
isUnread || isActive
? "text-foreground"
: "text-muted-foreground group-hover/button:text-foreground",
)}
>
{PERSONAL_CHANNEL_NAME}
</span>
{/* The lock and the hover "+" share the right edge, so fade the lock
out as the "+" comes in. */}
<LockSimpleIcon
size={12}
className={cn(
"ml-auto shrink-0 text-gray-9 transition-opacity",
"ml-auto shrink-0 text-chrome-foreground transition-opacity",
newMenuOpen
? "opacity-0"
: "opacity-100 group-hover/chan:opacity-0",
Expand Down Expand Up @@ -655,7 +697,12 @@ function ChannelGroup({
return (
<Collapsible.Root
open={isOpen}
onOpenChange={() => toggleSection(sectionId)}
// The store only exposes a toggle, so drive it from the requested value:
// an event for the state we're already in is then a no-op rather than an
// inversion.
onOpenChange={(open) => {
if (open !== isOpen) toggleSection(sectionId);
}}
className={className}
>
{/* MenuLabel carries the sidebar's label styling; `render` keeps it a
Expand Down Expand Up @@ -703,6 +750,8 @@ export function ChannelsList() {
const { channels: allChannels, isLoading } = useChannels();
const { starredRefToShortcutId } = useChannelStars();

const isUnread = useIsChannelUnread();

// 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));
Expand Down Expand Up @@ -733,7 +782,11 @@ export function ChannelsList() {
{starred.length > 0 && (
<ChannelGroup sectionId={STARRED_SECTION_ID} label="Starred">
{starred.map((channel) => (
<ChannelSection key={channel.id} channel={channel} />
<ChannelSection
key={channel.id}
channel={channel}
isUnread={isUnread(channel.name)}
/>
))}
</ChannelGroup>
)}
Expand All @@ -745,7 +798,11 @@ export function ChannelsList() {
</Empty>
)}
{others.map((channel) => (
<ChannelSection key={channel.id} channel={channel} />
<ChannelSection
key={channel.id}
channel={channel}
isUnread={isUnread(channel.name)}
/>
))}
</ChannelGroup>
</Flex>
Expand Down
Loading
Loading