Skip to content

Commit f9e830c

Browse files
adamleithpclaude
andauthored
feat(channels): bold a channel name when it has unseen activity (#3513)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b092f19 commit f9e830c

12 files changed

Lines changed: 606 additions & 19 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
latestActivityForChannel,
3+
unreadChannelIds,
4+
} from "@posthog/core/canvas/channelUnread";
5+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
6+
import { describe, expect, it } from "vitest";
7+
8+
function mention(
9+
overrides: Partial<MentionActivityItem> & { createdAt: string },
10+
): MentionActivityItem {
11+
return {
12+
messageId: `m-${overrides.createdAt}`,
13+
taskId: "t1",
14+
taskTitle: "Task",
15+
channelId: "c1",
16+
channelName: "mobile",
17+
author: null,
18+
content: "hey @adam",
19+
...overrides,
20+
};
21+
}
22+
23+
describe("unreadChannelIds", () => {
24+
const cases: {
25+
name: string;
26+
lastSeen: Record<string, string>;
27+
expected: string[];
28+
}[] = [
29+
{
30+
name: "a channel never seen is unread",
31+
lastSeen: {},
32+
expected: ["c1"],
33+
},
34+
{
35+
name: "activity newer than the last visit is unread",
36+
lastSeen: { c1: "2026-07-16T09:00:00.000Z" },
37+
expected: ["c1"],
38+
},
39+
{
40+
name: "activity older than the last visit is read",
41+
lastSeen: { c1: "2026-07-16T11:00:00.000Z" },
42+
expected: [],
43+
},
44+
{
45+
name: "activity exactly at the last visit is read",
46+
lastSeen: { c1: "2026-07-16T10:00:00.000Z" },
47+
expected: [],
48+
},
49+
];
50+
it.each(cases)("$name", ({ lastSeen, expected }) => {
51+
const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })];
52+
expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected);
53+
});
54+
55+
it("compares each channel against its own last visit", () => {
56+
const items = [
57+
mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }),
58+
mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }),
59+
];
60+
const unread = unreadChannelIds(items, {
61+
c1: "2026-07-16T11:00:00.000Z",
62+
c2: "2026-07-16T09:00:00.000Z",
63+
});
64+
expect([...unread]).toEqual(["c2"]);
65+
});
66+
67+
it("uses the newest item in a channel, whatever the order", () => {
68+
const items = [
69+
mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }),
70+
mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }),
71+
];
72+
expect([
73+
...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }),
74+
]).toEqual(["c1"]);
75+
});
76+
77+
it("ignores channel-less mentions", () => {
78+
const items = [
79+
mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }),
80+
];
81+
expect([...unreadChannelIds(items, {})]).toEqual([]);
82+
});
83+
});
84+
85+
describe("latestActivityForChannel", () => {
86+
it("returns the newest timestamp for that channel only", () => {
87+
const items = [
88+
mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }),
89+
mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }),
90+
mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }),
91+
];
92+
expect(latestActivityForChannel(items, "c1")).toBe(
93+
"2026-07-16T12:00:00.000Z",
94+
);
95+
});
96+
97+
it("is undefined for a channel with no activity, or no channel", () => {
98+
expect(latestActivityForChannel([], "c1")).toBeUndefined();
99+
expect(latestActivityForChannel([], undefined)).toBeUndefined();
100+
});
101+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
2+
3+
/**
4+
* Which channels have activity the viewer hasn't seen — the signal behind the
5+
* sidebar's bold channel names.
6+
*
7+
* "Activity" is currently an @-mention: that's the only cross-channel,
8+
* all-users feed the client has (the mentions index), and it's what
9+
* "notification" means elsewhere in the app. The backend exposes no per-channel
10+
* activity timestamp, so a broader "any new message" signal would mean polling
11+
* every user's full task list — the app's heaviest poll, deliberately retired.
12+
* If that timestamp lands, only `latestActivityByChannel` changes shape; the
13+
* unread comparison and the seen bookkeeping stay as they are.
14+
*
15+
* Keyed by backend channel id rather than name, so renaming a channel doesn't
16+
* silently mark it unread again.
17+
*/
18+
19+
/** Newest activity per channel id. Ignores items with no channel. */
20+
export function latestActivityByChannel(
21+
items: readonly MentionActivityItem[],
22+
): Map<string, string> {
23+
const latest = new Map<string, string>();
24+
for (const item of items) {
25+
if (!item.channelId) continue;
26+
const current = latest.get(item.channelId);
27+
if (!current || item.createdAt > current) {
28+
latest.set(item.channelId, item.createdAt);
29+
}
30+
}
31+
return latest;
32+
}
33+
34+
/**
35+
* Channel ids whose newest activity postdates the viewer's last visit. A
36+
* channel never visited is unread as soon as it has any activity.
37+
*/
38+
export function unreadChannelIds(
39+
items: readonly MentionActivityItem[],
40+
lastSeenByChannel: Readonly<Record<string, string>>,
41+
): Set<string> {
42+
const unread = new Set<string>();
43+
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
44+
const seenAt = lastSeenByChannel[channelId];
45+
if (!seenAt || activityAt > seenAt) unread.add(channelId);
46+
}
47+
return unread;
48+
}
49+
50+
/**
51+
* The newest activity in one channel, for stamping it seen while it's open.
52+
* Scans for the one channel rather than reusing `latestActivityByChannel`,
53+
* which would build (and throw away) a map of every other channel to answer.
54+
*/
55+
export function latestActivityForChannel(
56+
items: readonly MentionActivityItem[],
57+
channelId: string | undefined,
58+
): string | undefined {
59+
if (!channelId) return undefined;
60+
let latest: string | undefined;
61+
for (const item of items) {
62+
if (item.channelId !== channelId) continue;
63+
if (!latest || item.createdAt > latest) latest = item.createdAt;
64+
}
65+
return latest;
66+
}

packages/ui/src/features/canvas/components/ChannelHeader.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { HashIcon } from "@phosphor-icons/react";
22
import { Button, cn } from "@posthog/quill";
33
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
44
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
5+
import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen";
56
import { Text } from "@radix-ui/themes";
67
import { useNavigate, useRouterState } from "@tanstack/react-router";
78

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

2125
return (
2226
<div className="flex min-w-0 items-center gap-2">

packages/ui/src/features/canvas/components/ChannelsList.tsx

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
PERSONAL_CHANNEL_NAME,
6161
useTaskChannels,
6262
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
63+
import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels";
6364
import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink";
6465
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
6566
import { toast } from "@posthog/ui/primitives/toast";
@@ -291,7 +292,14 @@ function ChannelMenu({
291292

292293
// One channel in the list: a "# name" row that navigates to the channel home.
293294
// No expansion — the channel's surfaces live in the in-channel top nav.
294-
function ChannelSection({ channel }: { channel: Channel }) {
295+
function ChannelSection({
296+
channel,
297+
isUnread,
298+
}: {
299+
channel: Channel;
300+
/** Bolds the name: activity here the viewer hasn't seen. */
301+
isUnread?: boolean;
302+
}) {
295303
const navigate = useNavigate();
296304
const pathname = useRouterState({ select: (s) => s.location.pathname });
297305
const base = `/website/${channel.id}`;
@@ -340,10 +348,26 @@ function ChannelSection({ channel }: { channel: Channel }) {
340348
}}
341349
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
342350
>
343-
<HashIcon size={14} className="shrink-0 text-gray-9" />
351+
<HashIcon
352+
size={14}
353+
weight={isUnread ? "bold" : undefined}
354+
className={cn(
355+
"shrink-0",
356+
isUnread || isActive
357+
? "text-foreground"
358+
: "text-muted-foreground group-hover/button:text-foreground",
359+
)}
360+
/>
344361
<span
345362
className={cn(
346-
"truncate font-medium text-[13px] text-gray-12 group-hover/chan:pr-8",
363+
"truncate text-[13px] group-hover/chan:pr-8",
364+
// Bold is unread's alone; full contrast is shared with the
365+
// channel you're in. Either way there's no hover brighten
366+
// left to do, so those rows skip it.
367+
isUnread ? "font-bold" : "font-medium",
368+
isUnread || isActive
369+
? "text-foreground"
370+
: "text-muted-foreground group-hover/button:text-foreground",
347371
menuOpen && "pr-8",
348372
)}
349373
>
@@ -498,6 +522,7 @@ function PersonalChannelRow() {
498522
useTaskChannels();
499523
// The "+" dropdown (New task / New canvas), mirroring a shared channel row.
500524
const [newMenuOpen, setNewMenuOpen] = useState(false);
525+
const isUnread = useIsChannelUnread()(PERSONAL_CHANNEL_NAME);
501526

502527
const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME);
503528
const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id);
@@ -560,16 +585,33 @@ function PersonalChannelRow() {
560585
onClick={() => void open()}
561586
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
562587
>
563-
<HashIcon size={14} className="shrink-0 text-gray-9" />
564-
<span className="truncate font-medium text-[13px] text-gray-12">
588+
<HashIcon
589+
size={14}
590+
weight={isUnread ? "bold" : undefined}
591+
className={cn(
592+
"shrink-0",
593+
isUnread || isActive
594+
? "text-foreground"
595+
: "text-muted-foreground group-hover/button:text-foreground",
596+
)}
597+
/>
598+
<span
599+
className={cn(
600+
"truncate text-[13px]",
601+
isUnread ? "font-bold" : "font-medium",
602+
isUnread || isActive
603+
? "text-foreground"
604+
: "text-muted-foreground group-hover/button:text-foreground",
605+
)}
606+
>
565607
{PERSONAL_CHANNEL_NAME}
566608
</span>
567609
{/* The lock and the hover "+" share the right edge, so fade the lock
568610
out as the "+" comes in. */}
569611
<LockSimpleIcon
570612
size={12}
571613
className={cn(
572-
"ml-auto shrink-0 text-gray-9 transition-opacity",
614+
"ml-auto shrink-0 text-chrome-foreground transition-opacity",
573615
newMenuOpen
574616
? "opacity-0"
575617
: "opacity-100 group-hover/chan:opacity-0",
@@ -655,7 +697,12 @@ function ChannelGroup({
655697
return (
656698
<Collapsible.Root
657699
open={isOpen}
658-
onOpenChange={() => toggleSection(sectionId)}
700+
// The store only exposes a toggle, so drive it from the requested value:
701+
// an event for the state we're already in is then a no-op rather than an
702+
// inversion.
703+
onOpenChange={(open) => {
704+
if (open !== isOpen) toggleSection(sectionId);
705+
}}
659706
className={className}
660707
>
661708
{/* MenuLabel carries the sidebar's label styling; `render` keeps it a
@@ -703,6 +750,8 @@ export function ChannelsList() {
703750
const { channels: allChannels, isLoading } = useChannels();
704751
const { starredRefToShortcutId } = useChannelStars();
705752

753+
const isUnread = useIsChannelUnread();
754+
706755
// The "me" folder renders as the pinned personal row, not a shared channel.
707756
const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME);
708757
const starred = channels.filter((c) => starredRefToShortcutId.has(c.path));
@@ -733,7 +782,11 @@ export function ChannelsList() {
733782
{starred.length > 0 && (
734783
<ChannelGroup sectionId={STARRED_SECTION_ID} label="Starred">
735784
{starred.map((channel) => (
736-
<ChannelSection key={channel.id} channel={channel} />
785+
<ChannelSection
786+
key={channel.id}
787+
channel={channel}
788+
isUnread={isUnread(channel.name)}
789+
/>
737790
))}
738791
</ChannelGroup>
739792
)}
@@ -745,7 +798,11 @@ export function ChannelsList() {
745798
</Empty>
746799
)}
747800
{others.map((channel) => (
748-
<ChannelSection key={channel.id} channel={channel} />
801+
<ChannelSection
802+
key={channel.id}
803+
channel={channel}
804+
isUnread={isUnread(channel.name)}
805+
/>
749806
))}
750807
</ChannelGroup>
751808
</Flex>

0 commit comments

Comments
 (0)